目录
1. 简介
UIScrollView 是 iOS 中用于显示大于屏幕尺寸内容的核心视图类。它提供了滚动、缩放、分页等功能,是 UITableView、UICollectionView、UITextView 等的基类。
1.2 基本创建和配置
// 基本创建
let scrollView = UIScrollView()
scrollView.frame = view.bounds
scrollView.contentSize = CGSize(width: view.bounds.width, height: 2000)
view.addSubview(scrollView)
// 使用 Auto Layout
scrollView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
2. 基本属性
2.1 框架和内容尺寸属性
| 属性 | 类型 | 说明 | 默认值 |
|---|
frame | CGRect | ScrollView 在父视图中的位置和大小 | CGRect.zero |
bounds | CGRect | ScrollView 的边界,origin 表示当前滚动位置 | CGRect.zero |
contentSize | CGSize | 可滚动内容的总大小 | CGSize.zero |
contentOffset | CGPoint | 当前滚动位置的偏移量 | CGPoint.zero |
contentInset | UIEdgeInsets | 内容视图的额外滚动区域 | UIEdgeInsets.zero |
adjustedContentInset | UIEdgeInsets | 系统调整后的内容边距(只读) | - |
safeAreaInsets | UIEdgeInsets | 安全区域边距(只读) | - |
contentInsetAdjustmentBehavior | ContentInsetAdjustmentBehavior | 内容边距自动调整行为 | .automatic |
2.2 使用示例
class ScrollViewBasicExample: UIViewController {
let scrollView = UIScrollView()
override func viewDidLoad() {
super.viewDidLoad()
// 设置 frame
scrollView.frame = view.bounds
// 设置内容大小(必须大于 frame 才能滚动)
scrollView.contentSize = CGSize(width: view.bounds.width, height: 2000)
// 设置初始滚动位置
scrollView.contentOffset = CGPoint(x: 0, y: 100)
// 设置内容边距(常用于适配导航栏或标签栏)
scrollView.contentInset = UIEdgeInsets(top: 20, left: 0, bottom: 50, right: 0)
// 内容边距调整行为
scrollView.contentInsetAdjustmentBehavior = .automatic
// 可选值:
// .automatic - 自动调整
// .scrollableAxes - 仅在可滚动方向上调整
// .never - 从不调整
// .always - 始终调整
view.addSubview(scrollView)
}
}
3. 内容管理
3.1 内容布局属性
| 属性 | 类型 | 说明 |
|---|
contentLayoutGuide | UILayoutGuide | 表示内容区域的布局指南 |
frameLayoutGuide | UILayoutGuide | 表示 ScrollView frame 的布局指南 |
preservesSuperviewLayoutMargins | Bool | 是否保留父视图的布局边距 |
layoutMargins | UIEdgeInsets | 布局边距 |
3.2 Auto Layout 使用
class AutoLayoutScrollView: UIViewController {
let scrollView = UIScrollView()
let contentView = UIView()
override func viewDidLoad() {
super.viewDidLoad()
setupScrollView()
setupContent()
}
func setupScrollView() {
view.addSubview(scrollView)
scrollView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
func setupContent() {
scrollView.addSubview(contentView)
contentView.translatesAutoresizingMaskIntoConstraints = false
// 使用 contentLayoutGuide 定义可滚动区域
NSLayoutConstraint.activate([
contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
// 使用 frameLayoutGuide 定义内容宽度(防止水平滚动)
contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
// 设置内容高度
contentView.heightAnchor.constraint(equalToConstant: 2000)
])
}
}
4. 滚动行为
4.1 滚动控制属性
| 属性 | 类型 | 说明 | 默认值 |
|---|
isScrollEnabled | Bool | 是否允许用户滚动 | true |
isDirectionalLockEnabled | Bool | 是否锁定滚动到单一方向 | false |
isPagingEnabled | Bool | 是否启用分页滚动 | false |
scrollsToTop | Bool | 点击状态栏是否滚动到顶部 | true |
bounces | Bool | 是否有弹性效果 | true |
alwaysBounceVertical | Bool | 垂直方向始终有弹性 | false |
alwaysBounceHorizontal | Bool | 水平方向始终有弹性 | false |
canCancelContentTouches | Bool | 是否可以取消内容触摸 | true |
delaysContentTouches | Bool | 是否延迟内容触摸 | true |
decelerationRate | UIScrollView.DecelerationRate | 减速率 | .normal |
4.2 滚动状态属性(只读)
| 属性 | 类型 | 说明 |
|---|
isTracking | Bool | 用户是否正在触摸屏幕 |
isDragging | Bool | 用户是否正在拖动 |
isDecelerating | Bool | 是否正在减速 |
isZooming | Bool | 是否正在缩放 |
isZoomBouncing | Bool | 缩放是否正在弹性动画 |
4.3 滚动方法
class ScrollingMethodsExample {
let scrollView = UIScrollView()
// 滚动到指定位置
func scrollToPosition() {
// 方法1:直接设置 contentOffset
scrollView.contentOffset = CGPoint(x: 0, y: 500)
// 方法2:带动画的滚动
scrollView.setContentOffset(CGPoint(x: 0, y: 500), animated: true)
}
// 滚动到指定矩形可见
func scrollToRect() {
let rect = CGRect(x: 100, y: 500, width: 200, height: 200)
scrollView.scrollRectToVisible(rect, animated: true)
}
// 停止滚动
func stopScrolling() {
scrollView.setContentOffset(scrollView.contentOffset, animated: false)
}
// 闪烁滚动指示器
func flashIndicators() {
scrollView.flashScrollIndicators()
}
}
4.4 分页滚动示例
class PagingScrollView: UIViewController {
let scrollView = UIScrollView()
let pageControl = UIPageControl()
let numberOfPages = 3
override func viewDidLoad() {
super.viewDidLoad()
setupPagingScrollView()
setupPages()
}
func setupPagingScrollView() {
scrollView.frame = view.bounds
scrollView.isPagingEnabled = true
scrollView.showsHorizontalScrollIndicator = false
scrollView.delegate = self
view.addSubview(scrollView)
// 设置内容大小为多个页面
let pageWidth = scrollView.frame.width
scrollView.contentSize = CGSize(
width: pageWidth * CGFloat(numberOfPages),
height: scrollView.frame.height
)
// 配置页面控制器
pageControl.numberOfPages = numberOfPages
pageControl.currentPage = 0
pageControl.addTarget(self, action: #selector(pageControlChanged), for: .valueChanged)
view.addSubview(pageControl)
}
func setupPages() {
for i in 0..<numberOfPages {
let pageView = UIView(frame: CGRect(
x: scrollView.frame.width * CGFloat(i),
y: 0,
width: scrollView.frame.width,
height: scrollView.frame.height
))
pageView.backgroundColor = [UIColor.red, UIColor.green, UIColor.blue][i]
scrollView.addSubview(pageView)
}
}
@objc func pageControlChanged() {
let pageWidth = scrollView.frame.width
let offset = CGPoint(x: pageWidth * CGFloat(pageControl.currentPage), y: 0)
scrollView.setContentOffset(offset, animated: true)
}
}
extension PagingScrollView: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let pageWidth = scrollView.frame.width
let currentPage = Int((scrollView.contentOffset.x + pageWidth / 2) / pageWidth)
pageControl.currentPage = currentPage
}
}
5. 滚动指示器
5.1 指示器属性
| 属性 | 类型 | 说明 | 默认值 |
|---|
showsHorizontalScrollIndicator | Bool | 显示水平滚动条 | true |
showsVerticalScrollIndicator | Bool | 显示垂直滚动条 | true |
indicatorStyle | UIScrollView.IndicatorStyle | 指示器样式 | .default |
scrollIndicatorInsets | UIEdgeInsets | 滚动指示器边距 | .zero |
horizontalScrollIndicatorInsets | UIEdgeInsets | 水平滚动指示器边距 | .zero |
verticalScrollIndicatorInsets | UIEdgeInsets | 垂直滚动指示器边距 | .zero |
automaticallyAdjustsScrollIndicatorInsets | Bool | 自动调整滚动指示器边距 | true |
5.2 使用示例
class ScrollIndicatorExample {
let scrollView = UIScrollView()
func configureIndicators() {
// 隐藏滚动指示器
scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false
// 设置指示器样式
scrollView.indicatorStyle = .white // .default, .black, .white
// 设置指示器边距(避免被其他UI遮挡)
scrollView.scrollIndicatorInsets = UIEdgeInsets(top: 10, left: 0, bottom: 10, right: 5)
// 闪烁指示器(提示用户可以滚动)
scrollView.flashScrollIndicators()
// 自动调整指示器边距
scrollView.automaticallyAdjustsScrollIndicatorInsets = true
}
}
6. 缩放功能
6.1 缩放属性
| 属性 | 类型 | 说明 | 默认值 |
|---|
minimumZoomScale | CGFloat | 最小缩放比例 | 1.0 |
maximumZoomScale | CGFloat | 最大缩放比例 | 1.0 |
zoomScale | CGFloat | 当前缩放比例 | 1.0 |
bouncesZoom | Bool | 缩放时是否有弹性效果 | true |
6.2 缩放方法
// 设置缩放比例
func setZoomScale(_ scale: CGFloat, animated: Bool)
// 缩放到指定矩形区域
func zoom(to rect: CGRect, animated: Bool)
6.3 完整的图片缩放示例
class ImageZoomScrollView: UIViewController {
let scrollView = UIScrollView()
let imageView = UIImageView()
override func viewDidLoad() {
super.viewDidLoad()
setupScrollView()
setupImageView()
setupGestures()
}
func setupScrollView() {
scrollView.frame = view.bounds
scrollView.delegate = self
scrollView.minimumZoomScale = 0.5
scrollView.maximumZoomScale = 3.0
scrollView.bouncesZoom = true
scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false
view.addSubview(scrollView)
}
func setupImageView() {
imageView.image = UIImage(named: "sample")
imageView.contentMode = .scaleAspectFit
imageView.frame = scrollView.bounds
scrollView.addSubview(imageView)
// 设置内容大小
scrollView.contentSize = imageView.frame.size
}
func setupGestures() {
// 双击缩放
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:)))
doubleTap.numberOfTapsRequired = 2
scrollView.addGestureRecognizer(doubleTap)
}
@objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
if scrollView.zoomScale > scrollView.minimumZoomScale {
// 还原
scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true)
} else {
// 放大到点击位置
let location = gesture.location(in: imageView)
let rect = CGRect(x: location.x - 50, y: location.y - 50, width: 100, height: 100)
scrollView.zoom(to: rect, animated: true)
}
}
func centerImage() {
let offsetX = max((scrollView.bounds.width - scrollView.contentSize.width) * 0.5, 0)
let offsetY = max((scrollView.bounds.height - scrollView.contentSize.height) * 0.5, 0)
imageView.center = CGPoint(
x: scrollView.contentSize.width * 0.5 + offsetX,
y: scrollView.contentSize.height * 0.5 + offsetY
)
}
}
extension ImageZoomScrollView: UIScrollViewDelegate {
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return imageView
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
centerImage()
}
}
7. 键盘管理
7.1 键盘相关属性
| 属性 | 类型 | 说明 |
|---|
keyboardDismissMode | UIScrollView.KeyboardDismissMode | 键盘消失模式 |
7.2 键盘消失模式
// 键盘消失模式
public enum KeyboardDismissMode: Int {
case none = 0 // 不自动消失
case onDrag = 1 // 拖动时消失
case interactive = 2 // 交互式消失(可以跟随手指)
}
7.3 键盘处理示例
class KeyboardHandlingScrollView: UIViewController {
@IBOutlet weak var scrollView: UIScrollView!
var activeTextField: UITextField?
override func viewDidLoad() {
super.viewDidLoad()
setupKeyboardHandling()
}
func setupKeyboardHandling() {
// 设置键盘消失模式
scrollView.keyboardDismissMode = .interactive
// 监听键盘通知
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillShow),
name: UIResponder.keyboardWillShowNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillHide),
name: UIResponder.keyboardWillHideNotification,
object: nil
)
}
@objc func keyboardWillShow(notification: NSNotification) {
guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
let animationDuration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double
else { return }
UIView.animate(withDuration: animationDuration) {
self.scrollView.contentInset.bottom = keyboardFrame.height
self.scrollView.verticalScrollIndicatorInsets.bottom = keyboardFrame.height
// 滚动到活动文本框
if let textField = self.activeTextField {
let rect = textField.convert(textField.bounds, to: self.scrollView)
self.scrollView.scrollRectToVisible(rect, animated: false)
}
}
}
@objc func keyboardWillHide(notification: NSNotification) {
guard let animationDuration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double
else { return }
UIView.animate(withDuration: animationDuration) {
self.scrollView.contentInset.bottom = 0
self.scrollView.verticalScrollIndicatorInsets.bottom = 0
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
8. 刷新控制
8.1 刷新控件属性
| 属性 | 类型 | 说明 |
|---|
refreshControl | UIRefreshControl? | 下拉刷新控件 |
8.2 下拉刷新实现
class RefreshableScrollView: UIViewController {
let scrollView = UIScrollView()
let refreshControl = UIRefreshControl()
override func viewDidLoad() {
super.viewDidLoad()
setupRefreshControl()
}
func setupRefreshControl() {
// 配置刷新控件
refreshControl.tintColor = .systemBlue
refreshControl.attributedTitle = NSAttributedString(
string: "下拉刷新",
attributes: [.foregroundColor: UIColor.gray]
)
// 添加刷新事件
refreshControl.addTarget(
self,
action: #selector(handleRefresh),
for: .valueChanged
)
// 添加到 ScrollView
scrollView.refreshControl = refreshControl
}
@objc func handleRefresh() {
// 更新标题
refreshControl.attributedTitle = NSAttributedString(
string: "加载中...",
attributes: [.foregroundColor: UIColor.gray]
)
// 模拟网络请求
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
// 更新数据
self.loadData()
// 结束刷新
self.refreshControl.endRefreshing()
// 恢复标题
self.refreshControl.attributedTitle = NSAttributedString(
string: "下拉刷新",
attributes: [.foregroundColor: UIColor.gray]
)
}
}
func loadData() {
// 加载数据的逻辑
}
}
9.1 滚动相关代理方法
protocol UIScrollViewDelegate: NSObjectProtocol {
// MARK: - 滚动事件
// 滚动时持续调用(高频调用,注意性能)
optional func scrollViewDidScroll(_ scrollView: UIScrollView)
// 将要开始拖动
optional func scrollViewWillBeginDragging(_ scrollView: UIScrollView)
// 将要结束拖动
optional func scrollViewWillEndDragging(
_ scrollView: UIScrollView,
withVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>
)
// 已经结束拖动
optional func scrollViewDidEndDragging(
_ scrollView: UIScrollView,
willDecelerate decelerate: Bool
)
// 将要开始减速
optional func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView)
// 已经结束减速
optional func scrollViewDidEndDecelerating(_ scrollView: UIScrollView)
// 滚动动画结束(仅程序化滚动)
optional func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView)
// MARK: - 缩放事件
// 返回要缩放的视图(必须实现才能缩放)
optional func viewForZooming(in scrollView: UIScrollView) -> UIView?
// 将要开始缩放
optional func scrollViewWillBeginZooming(
_ scrollView: UIScrollView,
with view: UIView?
)
// 缩放时持续调用
optional func scrollViewDidZoom(_ scrollView: UIScrollView)
// 缩放结束
optional func scrollViewDidEndZooming(
_ scrollView: UIScrollView,
with view: UIView?,
atScale scale: CGFloat
)
// MARK: - 滚动到顶部
// 是否允许点击状态栏滚动到顶部
optional func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool
// 已经滚动到顶部
optional func scrollViewDidScrollToTop(_ scrollView: UIScrollView)
// MARK: - 内容偏移和边距
// 内容偏移改变时调用(iOS 11+)
optional func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView)
}
9.2 代理方法使用示例
class ScrollViewDelegateExample: UIViewController {
let scrollView = UIScrollView()
var lastContentOffset: CGPoint = .zero
override func viewDidLoad() {
super.viewDidLoad()
scrollView.delegate = self
}
}
extension ScrollViewDelegateExample: UIScrollViewDelegate {
// 检测滚动方向
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let currentOffset = scrollView.contentOffset
if currentOffset.y > lastContentOffset.y {
print("向上滚动")
} else if currentOffset.y < lastContentOffset.y {
print("向下滚动")
}
lastContentOffset = currentOffset
// 计算滚动进度
let progress = scrollView.contentOffset.y /
(scrollView.contentSize.height - scrollView.frame.height)
print("滚动进度: \(progress * 100)%")
}
// 自定义减速目标位置(实现自定义分页)
func scrollViewWillEndDragging(
_ scrollView: UIScrollView,
withVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>
) {
let pageWidth: CGFloat = 200 // 自定义页面宽度
let currentPage = round(targetContentOffset.pointee.x / pageWidth)
targetContentOffset.pointee.x = currentPage * pageWidth
}
// 判断是否停止滚动
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if !decelerate {
print("滚动停止(无减速)")
scrollingDidStop()
}
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
print("滚动停止(减速结束)")
scrollingDidStop()
}
func scrollingDidStop() {
// 滚动停止后的处理
}
// 缩放相关
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return scrollView.subviews.first
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
print("当前缩放比例: \(scrollView.zoomScale)")
}
}
10. 手势识别
10.1 内置手势识别器
// UIScrollView 内置的手势识别器
let panGesture = scrollView.panGestureRecognizer // 拖动手势
let pinchGesture = scrollView.pinchGestureRecognizer // 缩放手势(可选)
// 访问手势状态
if scrollView.panGestureRecognizer.state == .changed {
let translation = scrollView.panGestureRecognizer.translation(in: scrollView)
let velocity = scrollView.panGestureRecognizer.velocity(in: scrollView)
}
10.2 手势冲突处理
class GestureHandlingScrollView: UIScrollView {
override init(frame: CGRect) {
super.init(frame: frame)
setupGestures()
}
func setupGestures() {
// 添加自定义手势
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap))
tapGesture.delegate = self
addGestureRecognizer(tapGesture)
// 设置手势优先级
tapGesture.require(toFail: panGestureRecognizer)
}
@objc func handleTap(_ gesture: UITapGestureRecognizer) {
print("ScrollView 被点击")
}
}
extension GestureHandlingScrollView: UIGestureRecognizerDelegate {
// 允许多个手势同时识别
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
return true
}
// 控制手势是否应该开始
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
// 可以根据条件决定是否响应手势
if gestureRecognizer is UIPanGestureRecognizer {
let velocity = panGestureRecognizer.velocity(in: self)
// 只响应垂直滑动
return abs(velocity.y) > abs(velocity.x)
}
return true
}
// 处理手势冲突
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
// 如果是嵌套的 ScrollView,优先处理内部的
if otherGestureRecognizer.view is UIScrollView {
return true
}
return false
}
}
---
## 11. 实战示例
### 11.1 图片浏览器(支持缩放和分页)
```swift
class PhotoBrowser: UIViewController {
private var scrollView: UIScrollView!
private var pageControl: UIPageControl!
private var images: [UIImage] = []
private var imageScrollViews: [UIScrollView] = []
override func viewDidLoad() {
super.viewDidLoad()
setupMainScrollView()
setupPageControl()
loadImages()
}
private func setupMainScrollView() {
scrollView = UIScrollView(frame: view.bounds)
scrollView.isPagingEnabled = true
scrollView.showsHorizontalScrollIndicator = false
scrollView.delegate = self
view.addSubview(scrollView)
// 设置内容大小
let numberOfImages = 3
scrollView.contentSize = CGSize(
width: view.bounds.width * CGFloat(numberOfImages),
height: view.bounds.height
)
}
private func setupPageControl() {
pageControl = UIPageControl()
pageControl.numberOfPages = images.count
pageControl.currentPage = 0
pageControl.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(pageControl)
NSLayoutConstraint.activate([
pageControl.centerXAnchor.constraint(equalTo: view.centerXAnchor),
pageControl.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20)
])
}
private func loadImages() {
for (index, image) in images.enumerated() {
let imageScrollView = createImageScrollView(image: image, index: index)
scrollView.addSubview(imageScrollView)
imageScrollViews.append(imageScrollView)
}
}
private func createImageScrollView(image: UIImage, index: Int) -> UIScrollView {
let frame = CGRect(
x: view.bounds.width * CGFloat(index),
y: 0,
width: view.bounds.width,
height: view.bounds.height
)
let imageScrollView = UIScrollView(frame: frame)
imageScrollView.minimumZoomScale = 1.0
imageScrollView.maximumZoomScale = 3.0
imageScrollView.delegate = self
let imageView = UIImageView(image: image)
imageView.contentMode = .scaleAspectFit
imageView.frame = imageScrollView.bounds
imageView.tag = index + 1000
imageScrollView.addSubview(imageView)
// 双击缩放
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:)))
doubleTap.numberOfTapsRequired = 2
imageScrollView.addGestureRecognizer(doubleTap)
return imageScrollView
}
@objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
guard let scrollView = gesture.view as? UIScrollView else { return }
if scrollView.zoomScale > 1.0 {
scrollView.setZoomScale(1.0, animated: true)
} else {
let location = gesture.location(in: scrollView)
let rect = CGRect(x: location.x - 50, y: location.y - 50, width: 100, height: 100)
scrollView.zoom(to: rect, animated: true)
}
}
}
extension PhotoBrowser: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView == self.scrollView {
let pageIndex = round(scrollView.contentOffset.x / view.bounds.width)
pageControl.currentPage = Int(pageIndex)
}
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
if scrollView != self.scrollView {
return scrollView.subviews.first
}
return nil
}
}
11.2 无限滚动列表
class InfiniteScrollView: UIViewController {
@IBOutlet weak var scrollView: UIScrollView!
private var items: [String] = []
private var isLoading = false
private let itemHeight: CGFloat = 100
override func viewDidLoad() {
super.viewDidLoad()
scrollView.delegate = self
loadInitialData()
}
private func loadInitialData() {
// 加载初始数据
for i in 0..<20 {
items.append("Item \(i)")
}
updateScrollViewContent()
}
private func loadMoreData() {
guard !isLoading else { return }
isLoading = true
// 模拟网络加载
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
let currentCount = self.items.count
for i in currentCount..<(currentCount + 20) {
self.items.append("Item \(i)")
}
self.updateScrollViewContent()
self.isLoading = false
}
}
private func updateScrollViewContent() {
// 清除旧视图
scrollView.subviews.forEach { $0.removeFromSuperview() }
// 添加新视图
for (index, item) in items.enumerated() {
let itemView = createItemView(text: item, index: index)
scrollView.addSubview(itemView)
}
// 更新内容大小
scrollView.contentSize = CGSize(
width: scrollView.frame.width,
height: CGFloat(items.count) * itemHeight
)
}
private func createItemView(text: String, index: Int) -> UIView {
let frame = CGRect(
x: 0,
y: CGFloat(index) * itemHeight,
width: scrollView.frame.width,
height: itemHeight
)
let view = UIView(frame: frame)
view.backgroundColor = index % 2 == 0 ? .systemGray6 : .systemGray5
let label = UILabel(frame: view.bounds.insetBy(dx: 20, dy: 0))
label.text = text
view.addSubview(label)
return view
}
}
extension InfiniteScrollView: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetY = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height
let scrollViewHeight = scrollView.frame.height
// 当滚动到底部附近时加载更多
if offsetY > contentHeight - scrollViewHeight - 200 {
loadMoreData()
}
}
}
11.3 视差滚动效果(Parallax Effect)
class ParallaxScrollViewController: UIViewController {
private var scrollView: UIScrollView!
private var headerImageView: UIImageView!
private var contentView: UIView!
private let headerHeight: CGFloat = 300
override func viewDidLoad() {
super.viewDidLoad()
setupScrollView()
setupHeader()
setupContent()
}
private func setupScrollView() {
scrollView = UIScrollView(frame: view.bounds)
scrollView.contentInsetAdjustmentBehavior = .never
scrollView.delegate = self
view.addSubview(scrollView)
}
private func setupHeader() {
headerImageView = UIImageView(frame: CGRect(
x: 0,
y: 0,
width: view.bounds.width,
height: headerHeight
))
headerImageView.image = UIImage(named: "header")
headerImageView.contentMode = .scaleAspectFill
headerImageView.clipsToBounds = true
view.addSubview(headerImageView) // 注意:添加到 view 而不是 scrollView
}
private func setupContent() {
contentView = UIView(frame: CGRect(
x: 0,
y: 0,
width: view.bounds.width,
height: 1000
))
contentView.backgroundColor = .white
// 添加内容到 contentView
let label = UILabel(frame: CGRect(x: 20, y: headerHeight + 20, width: view.bounds.width - 40, height: 40))
label.text = "Scroll for parallax effect"
label.font = .boldSystemFont(ofSize: 24)
contentView.addSubview(label)
scrollView.addSubview(contentView)
scrollView.contentSize = contentView.frame.size
// 设置初始 contentInset
scrollView.contentInset = UIEdgeInsets(top: headerHeight, left: 0, bottom: 0, right: 0)
scrollView.contentOffset = CGPoint(x: 0, y: -headerHeight)
}
}
extension ParallaxScrollViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetY = scrollView.contentOffset.y
if offsetY < -headerHeight {
// 下拉放大效果
let scaleFactor = abs(offsetY + headerHeight) / headerHeight
let scale = 1 + scaleFactor
headerImageView.transform = CGAffineTransform(scaleX: scale, y: scale)
.concatenating(CGAffineTransform(translationX: 0, y: offsetY))
} else {
// 视差滚动效果
let parallaxFactor: CGFloat = 0.5
let translateY = offsetY * parallaxFactor
headerImageView.transform = CGAffineTransform(translationX: 0, y: translateY)
}
}
}
11.4 聊天界面(自动滚动到底部)
class ChatScrollView: UIViewController {
@IBOutlet weak var scrollView: UIScrollView!
@IBOutlet weak var inputTextField: UITextField!
@IBOutlet weak var bottomConstraint: NSLayoutConstraint!
private var messages: [String] = []
private let messageHeight: CGFloat = 60
override func viewDidLoad() {
super.viewDidLoad()
setupKeyboardObservers()
setupScrollView()
}
private func setupScrollView() {
scrollView.keyboardDismissMode = .interactive
// 添加点击手势隐藏键盘
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
scrollView.addGestureRecognizer(tapGesture)
}
private func setupKeyboardObservers() {
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillShow),
name: UIResponder.keyboardWillShowNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillHide),
name: UIResponder.keyboardWillHideNotification,
object: nil
)
}
@objc private func keyboardWillShow(notification: NSNotification) {
guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double
else { return }
bottomConstraint.constant = keyboardFrame.height
UIView.animate(withDuration: duration) {
self.view.layoutIfNeeded()
self.scrollToBottom()
}
}
@objc private func keyboardWillHide(notification: NSNotification) {
guard let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double
else { return }
bottomConstraint.constant = 0
UIView.animate(withDuration: duration) {
self.view.layoutIfNeeded()
}
}
@objc private func dismissKeyboard() {
view.endEditing(true)
}
@IBAction func sendMessage(_ sender: Any) {
guard let text = inputTextField.text, !text.isEmpty else { return }
messages.append(text)
addMessageView(text: text)
inputTextField.text = ""
scrollToBottom()
}
private func addMessageView(text: String) {
let messageView = createMessageView(text: text, index: messages.count - 1)
scrollView.addSubview(messageView)
// 更新内容大小
scrollView.contentSize = CGSize(
width: scrollView.frame.width,
height: CGFloat(messages.count) * messageHeight
)
}
private func createMessageView(text: String, index: Int) -> UIView {
let frame = CGRect(
x: 10,
y: CGFloat(index) * messageHeight + 10,
width: scrollView.frame.width - 20,
height: messageHeight - 10
)
let messageView = UIView(frame: frame)
messageView.backgroundColor = .systemBlue
messageView.layer.cornerRadius = 10
let label = UILabel(frame: messageView.bounds.insetBy(dx: 10, dy: 5))
label.text = text
label.textColor = .white
messageView.addSubview(label)
return messageView
}
private func scrollToBottom() {
let bottomOffset = CGPoint(
x: 0,
y: max(scrollView.contentSize.height - scrollView.bounds.height, 0)
)
scrollView.setContentOffset(bottomOffset, animated: true)
}
}
12. 性能优化
12.1 视图复用机制
class ReusableScrollView: UIScrollView {
private var visibleViews: Set<UIView> = []
private var reusableViews: Set<UIView> = []
private let viewHeight: CGFloat = 100
private var totalItems: Int = 1000
override func layoutSubviews() {
super.layoutSubviews()
recycleViews()
displayVisibleViews()
}
private func recycleViews() {
// 计算可见区域
let visibleRect = CGRect(
x: contentOffset.x,
y: contentOffset.y,
width: bounds.width,
height: bounds.height
)
// 回收不可见的视图
for view in visibleViews {
if !visibleRect.intersects(view.frame) {
view.removeFromSuperview()
visibleViews.remove(view)
reusableViews.insert(view)
}
}
}
private func displayVisibleViews() {
// 计算可见的索引范围
let firstVisibleIndex = max(0, Int(contentOffset.y / viewHeight))
let lastVisibleIndex = min(totalItems - 1, Int((contentOffset.y + bounds.height) / viewHeight))
// 显示可见范围内的视图
for index in firstVisibleIndex...lastVisibleIndex {
let viewFrame = CGRect(
x: 0,
y: CGFloat(index) * viewHeight,
width: bounds.width,
height: viewHeight
)
// 检查是否已经显示
let isDisplayed = visibleViews.contains { $0.frame == viewFrame }
if !isDisplayed {
let view = dequeueReusableView() ?? createNewView()
view.frame = viewFrame
configureView(view, at: index)
addSubview(view)
visibleViews.insert(view)
}
}
}
private func dequeueReusableView() -> UIView? {
guard let view = reusableViews.first else { return nil }
reusableViews.remove(view)
return view
}
private func createNewView() -> UIView {
let view = UIView()
// 配置视图
return view
}
private func configureView(_ view: UIView, at index: Int) {
// 配置视图内容
view.backgroundColor = index % 2 == 0 ? .systemGray6 : .white
}
}
12.2 优化滚动性能
class OptimizedScrollViewDelegate: NSObject, UIScrollViewDelegate {
private var lastUpdateTime: TimeInterval = 0
private let updateInterval: TimeInterval = 0.1 // 限制更新频率
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let currentTime = CACurrentMediaTime()
// 限制更新频率
guard currentTime - lastUpdateTime > updateInterval else { return }
lastUpdateTime = currentTime
// 异步处理重操作
DispatchQueue.global(qos: .userInitiated).async {
// 执行计算密集型操作
let result = self.performHeavyCalculation(scrollView.contentOffset)
DispatchQueue.main.async {
// 更新UI
self.updateUI(with: result)
}
}
}
private func performHeavyCalculation(_ offset: CGPoint) -> String {
// 模拟耗时操作
return "Offset: \(offset)"
}
private func updateUI(with result: String) {
// 更新界面
}
}
13. 常见问题与解决方案
// 问题诊断和解决
class ScrollViewTroubleshooting {
func diagnoseScrollingIssue(scrollView: UIScrollView) {
print("=== ScrollView 诊断 ===")
print("Frame: \(scrollView.frame)")
print("ContentSize: \(scrollView.contentSize)")
print("ContentOffset: \(scrollView.contentOffset)")
print("IsScrollEnabled: \(scrollView.isScrollEnabled)")
print("ContentInset: \(scrollView.contentInset)")
print("Subviews count: \(scrollView.subviews.count)")
// 常见问题检查
if scrollView.contentSize.height <= scrollView.frame.height &&
scrollView.contentSize.width <= scrollView.frame.width {
print("❌ 问题: ContentSize 小于或等于 frame,无法滚动")
print("✅ 解决: 确保 contentSize 大于 frame")
}
if !scrollView.isScrollEnabled {
print("❌ 问题: isScrollEnabled 为 false")
print("✅ 解决: scrollView.isScrollEnabled = true")
}
if scrollView.isUserInteractionEnabled == false {
print("❌ 问题: 用户交互被禁用")
print("✅ 解决: scrollView.isUserInteractionEnabled = true")
}
}
// 解决方案示例
func fixCommonIssues(scrollView: UIScrollView) {
// 1. 确保 contentSize 正确
var maxRect = CGRect.zero
for subview in scrollView.subviews {
maxRect = maxRect.union(subview.frame)
}
scrollView.contentSize = maxRect.size
// 2. 启用滚动
scrollView.isScrollEnabled = true
// 3. 启用用户交互
scrollView.isUserInteractionEnabled = true
// 4. 设置正确的 contentInset(如果需要)
if #available(iOS 11.0, *) {
scrollView.contentInsetAdjustmentBehavior = .automatic
}
}
}
class AutoLayoutScrollViewFix {
func setupScrollViewWithAutoLayout(in viewController: UIViewController) {
let scrollView = UIScrollView()
let contentView = UIView()
viewController.view.addSubview(scrollView)
scrollView.addSubview(contentView)
// 关闭 autoresizing mask
scrollView.translatesAutoresizingMaskIntoConstraints = false
contentView.translatesAutoresizingMaskIntoConstraints = false
// ScrollView 约束
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: viewController.view.safeAreaLayoutGuide.topAnchor),
scrollView.leadingAnchor.constraint(equalTo: viewController.view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: viewController.view.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: viewController.view.bottomAnchor)
])
// ContentView 约束 - 关键!
NSLayoutConstraint.activate([
// 定义可滚动区域
contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
// 定义内容宽度(防止水平滚动)
contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
// 定义内容高度(或让内容决定高度)
contentView.heightAnchor.constraint(equalToConstant: 2000)
])
}
}
class NestedScrollViewHandler: UIViewController {
@IBOutlet weak var outerScrollView: UIScrollView!
@IBOutlet weak var innerScrollView: UIScrollView!
override func viewDidLoad() {
super.viewDidLoad()
setupNestedScrollViews()
}
func setupNestedScrollViews() {
outerScrollView.delegate = self
innerScrollView.delegate = self
// 设置手势识别优先级
outerScrollView.panGestureRecognizer.require(toFail: innerScrollView.panGestureRecognizer)
}
}
extension NestedScrollViewHandler: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView == innerScrollView {
// 内部滚动到顶部或底部时,允许外部滚动
let isAtTop = scrollView.contentOffset.y <= 0
let isAtBottom = scrollView.contentOffset.y >=
(scrollView.contentSize.height - scrollView.frame.height)
outerScrollView.isScrollEnabled = isAtTop || isAtBottom
}
}
}
14. 最佳实践
14.1 设计模式
// MARK: - ScrollView 工厂模式
class ScrollViewFactory {
static func createVerticalScrollView(in view: UIView) -> UIScrollView {
let scrollView = UIScrollView()
scrollView.showsVerticalScrollIndicator = true
scrollView.showsHorizontalScrollIndicator = false
scrollView.alwaysBounceVertical = true
scrollView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(scrollView)
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
return scrollView
}
static func createPagedScrollView(numberOfPages: Int, in view: UIView) -> UIScrollView {
let scrollView = UIScrollView(frame: view.bounds)
scrollView.isPagingEnabled = true
scrollView.showsHorizontalScrollIndicator = false
scrollView.contentSize = CGSize(
width: view.bounds.width * CGFloat(numberOfPages),
height: view.bounds.height
)
return scrollView
}
}
14.2 扩展和工具方法
// MARK: - 有用的 ScrollView 扩展
extension UIScrollView {
// 滚动到顶部
func scrollToTop(animated: Bool = true) {
let topOffset = CGPoint(x: 0, y: -contentInset.top)
setContentOffset(topOffset, animated: animated)
}
// 滚动到底部
func scrollToBottom(animated: Bool = true) {
let bottomOffset = CGPoint(
x: 0,
y: contentSize.height - bounds.height + contentInset.bottom
)
if bottomOffset.y > 0 {
setContentOffset(bottomOffset, animated: animated)
}
}
// 是否已滚动到底部
var isAtBottom: Bool {
return contentOffset.y >= (contentSize.height - frame.height - 10)
}
// 是否已滚动到顶部
var isAtTop: Bool {
return contentOffset.y <= -contentInset.top
}
// 可见区域
var visibleRect: CGRect {
return CGRect(
x: contentOffset.x,
y: contentOffset.y,
width: bounds.width,
height: bounds.height
)
}
// 更新内容大小以适应所有子视图
func updateContentSizeToFitSubviews(padding: UIEdgeInsets = .zero) {
var contentRect = CGRect.zero
for view in subviews {
contentRect = contentRect.union(view.frame)
}
contentSize = CGSize(
width: contentRect.width + padding.left + padding.right,
height: contentRect.height + padding.top + padding.bottom
)
}
// 缩放到适应内容
func zoomToFit(animated: Bool = true) {
guard let delegate = delegate,
let view = delegate.viewForZooming?(in: self) else { return }
let widthScale = bounds.width / view.bounds.width
let heightScale = bounds.height / view.bounds.height
let scale = min(widthScale, heightScale)
setZoomScale(scale, animated: animated)
}
}
14.3 调试助手
// MARK: - ScrollView 调试
#if DEBUG
extension UIScrollView {
func enableDebugMode() {
layer.borderWidth = 2
layer.borderColor = UIColor.red.cgColor
// 显示内容区域
let contentView = UIView(frame: CGRect(origin: .zero, size: contentSize))
contentView.layer.borderWidth = 2
contentView.layer.borderColor = UIColor.blue.cgColor
contentView.backgroundColor = UIColor.blue.withAlphaComponent(0.1)
contentView.isUserInteractionEnabled = false
insertSubview(contentView, at: 0)
// 添加调试信息视图
addDebugInfoView()
}
private func addDebugInfoView() {
let debugLabel = UILabel()
debugLabel.backgroundColor = UIColor.black.withAlphaComponent(0.7)
debugLabel.textColor = .white
debugLabel.font = .systemFont(ofSize: 12)
debugLabel.numberOfLines = 0
debugLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(debugLabel)
NSLayoutConstraint.activate([
debugLabel.topAnchor.constraint(equalTo: topAnchor, constant: 50),
debugLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10),
debugLabel.widthAnchor.constraint(equalToConstant: 200)
])
// 更新调试信息
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
debugLabel.text = """
Offset: \(String(format: "%.1f, %.1f", self.contentOffset.x, self.contentOffset.y))
Content: \(String(format: "%.0f x %.0f", self.contentSize.width, self.contentSize.height))
Frame: \(String(format: "%.0f x %.0f", self.frame.width, self.frame.height))
Zoom: \(String(format: "%.2f", self.zoomScale))
"""
}
}
func printDebugInfo() {
print("""
=== UIScrollView Debug Info ===
Frame: \(frame)
Bounds: \(bounds)
ContentSize: \(contentSize)
ContentOffset: \(contentOffset)
ContentInset: \(contentInset)
AdjustedContentInset: \(adjustedContentInset)
ZoomScale: \(zoomScale) (min: \(minimumZoomScale), max: \(maximumZoomScale))
IsScrollEnabled: \(isScrollEnabled)
IsPagingEnabled: \(isPagingEnabled)
IsScrolling: \(isDragging || isDecelerating)
Subviews Count: \(subviews.count)
==============================
""")
}
}
#endif
14.4 内存管理最佳实践
class MemoryEfficientScrollView: UIViewController {
weak var scrollView: UIScrollView!
private var imageViews: [UIImageView] = []
override func viewDidLoad() {
super.viewDidLoad()
setupScrollView()
}
private func setupScrollView() {
let scrollView = UIScrollView()
self.scrollView = scrollView
view.addSubview(scrollView)
// 设置内存警告处理
NotificationCenter.default.addObserver(
self,
selector: #selector(handleMemoryWarning),
name: UIApplication.didReceiveMemoryWarningNotification,
object: nil
)
}
@objc private func handleMemoryWarning() {
// 清理不可见的图片
let visibleRect = scrollView.visibleRect
for imageView in imageViews {
if !visibleRect.intersects(imageView.frame) {
imageView.image = nil // 释放图片内存
}
}
}
deinit {
NotificationCenter.default.removeObserver(self)
// 清理资源
imageViews.forEach { $0.image = nil }
imageViews.removeAll()
}
}
14.5 响应式设计
class ResponsiveScrollView: UIViewController {
@IBOutlet weak var scrollView: UIScrollView!
override func viewDidLoad() {
super.viewDidLoad()
setupResponsiveLayout()
}
private func setupResponsiveLayout() {
// 监听设备方向变化
NotificationCenter.default.addObserver(
self,
selector: #selector(orientationChanged),
name: UIDevice.orientationDidChangeNotification,
object: nil
)
}
@objc private func orientationChanged() {
// 重新计算布局
updateScrollViewLayout()
}
private func updateScrollViewLayout() {
let isLandscape = UIDevice.current.orientation.isLandscape
// 根据方向调整内容
if isLandscape {
// 横屏布局
scrollView.contentSize = CGSize(
width: view.bounds.width * 2,
height: view.bounds.height
)
scrollView.isPagingEnabled = true
} else {
// 竖屏布局
scrollView.contentSize = CGSize(
width: view.bounds.width,
height: view.bounds.height * 2
)
scrollView.isPagingEnabled = false
}
// 重新排列子视图
rearrangeSubviews()
}
private func rearrangeSubviews() {
// 实现子视图重新排列逻辑
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { _ in
self.updateScrollViewLayout()
}, completion: nil)
}
}
15. 高级技巧
15.1 自定义滚动行为
class CustomScrollBehavior: UIScrollView {
override init(frame: CGRect) {
super.init(frame: frame)
setupCustomBehavior()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupCustomBehavior()
}
private func setupCustomBehavior() {
decelerationRate = UIScrollView.DecelerationRate(rawValue: 0.992) // 自定义减速率
}
// 自定义滚动到最近的页面
func scrollToNearestPage() {
let pageWidth: CGFloat = 100 // 自定义页面宽度
let currentPage = round(contentOffset.x / pageWidth)
let targetX = currentPage * pageWidth
setContentOffset(CGPoint(x: targetX, y: contentOffset.y), animated: true)
}
// 实现弹性边界
override func setContentOffset(_ contentOffset: CGPoint, animated: Bool) {
var adjustedOffset = contentOffset
// 添加弹性限制
let maxX = max(0, contentSize.width - bounds.width)
let maxY = max(0, contentSize.height - bounds.height)
adjustedOffset.x = min(max(0, adjustedOffset.x), maxX)
adjustedOffset.y = min(max(0, adjustedOffset.y), maxY)
super.setContentOffset(adjustedOffset, animated: animated)
}
}
15.2 自定义滚动指示器
class CustomScrollIndicator: UIView {
weak var scrollView: UIScrollView?
private let indicatorView = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
setupIndicator()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupIndicator()
}
private func setupIndicator() {
// 设置指示器样式
indicatorView.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.7)
indicatorView.layer.cornerRadius = 2
addSubview(indicatorView)
// 隐藏系统指示器
scrollView?.showsVerticalScrollIndicator = false
}
func updateIndicator() {
guard let scrollView = scrollView else { return }
let contentHeight = scrollView.contentSize.height
let scrollViewHeight = scrollView.bounds.height
guard contentHeight > scrollViewHeight else {
indicatorView.isHidden = true
return
}
indicatorView.isHidden = false
// 计算指示器高度
let indicatorHeight = (scrollViewHeight / contentHeight) * bounds.height
// 计算指示器位置
let progress = scrollView.contentOffset.y / (contentHeight - scrollViewHeight)
let indicatorY = progress * (bounds.height - indicatorHeight)
// 更新指示器 frame
indicatorView.frame = CGRect(
x: bounds.width - 6,
y: indicatorY,
width: 4,
height: indicatorHeight
)
}
}
15.3 智能加载和预加载
class SmartLoadingScrollView: UIViewController {
@IBOutlet weak var scrollView: UIScrollView!
private var loadedIndexes: Set<Int> = []
private let itemHeight: CGFloat = 100
private let preloadOffset: CGFloat = 200 // 预加载距离
override func viewDidLoad() {
super.viewDidLoad()
scrollView.delegate = self
}
}
extension SmartLoadingScrollView: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
loadVisibleContent()
preloadContent()
}
private func loadVisibleContent() {
let visibleRect = scrollView.visibleRect
let firstIndex = max(0, Int(visibleRect.minY / itemHeight))
let lastIndex = Int(visibleRect.maxY / itemHeight)
for index in firstIndex...lastIndex {
loadItemIfNeeded(at: index)
}
}
private func preloadContent() {
let preloadRect = CGRect(
x: scrollView.contentOffset.x,
y: scrollView.contentOffset.y - preloadOffset,
width: scrollView.bounds.width,
height: scrollView.bounds.height + preloadOffset * 2
)
let firstIndex = max(0, Int(preloadRect.minY / itemHeight))
let lastIndex = Int(preloadRect.maxY / itemHeight)
for index in firstIndex...lastIndex {
preloadItemIfNeeded(at: index)
}
}
private func loadItemIfNeeded(at index: Int) {
guard !loadedIndexes.contains(index) else { return }
// 加载内容
let itemView = createItemView(at: index)
scrollView.addSubview(itemView)
loadedIndexes.insert(index)
}
private func preloadItemIfNeeded(at index: Int) {
// 预加载逻辑(如预下载图片)
DispatchQueue.global(qos: .utility).async {
// 预加载操作
}
}
private func createItemView(at index: Int) -> UIView {
let frame = CGRect(
x: 0,
y: CGFloat(index) * itemHeight,
width: scrollView.bounds.width,
height: itemHeight
)
let view = UIView(frame: frame)
// 配置视图
return view
}
}
16. SwiftUI 集成
import SwiftUI
struct ScrollViewWrapper: UIViewRepresentable {
@Binding var contentOffset: CGPoint
let content: () -> UIView
func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator
let contentView = content()
scrollView.addSubview(contentView)
scrollView.contentSize = contentView.bounds.size
return scrollView
}
func updateUIView(_ scrollView: UIScrollView, context: Context) {
scrollView.contentOffset = contentOffset
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIScrollViewDelegate {
var parent: ScrollViewWrapper
init(_ parent: ScrollViewWrapper) {
self.parent = parent
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
parent.contentOffset = scrollView.contentOffset
}
}
}
// SwiftUI 中使用
struct ContentView: View {
@State private var scrollOffset = CGPoint.zero
var body: some View {
ScrollViewWrapper(contentOffset: $scrollOffset) {
// 创建 UIView 内容
let view = UIView(frame: CGRect(x: 0, y: 0, width: 300, height: 1000))
view.backgroundColor = .systemBlue
return view
}
.overlay(
Text("Offset: \(Int(scrollOffset.y))")
.padding()
.background(Color.white.opacity(0.8))
.cornerRadius(8)
.padding(),
alignment: .topTrailing
)
}
}
17. PagedHorizontalScrollView 可以水平分页滚动,可以禁止触摸滚动
import SwiftUI
//
// PagedHorizontalScrollView.swift
// wscanner
//
// Created by yunshen on 2025/9/16.
//
import UIKit
public struct PagedHorizontalScrollView: UIViewRepresentable {
var selectedTab: Binding<Int>
let views: [AnyView]
let isScrollEnabled: Bool
private var onPageSelected: ((Int) -> Void)? = nil
public init(selectedTab: Binding<Int>, views: [AnyView], isScrollEnabled: Bool, onPageSelected: ((Int) -> Void)? = nil) {
self.selectedTab = selectedTab
self.views = views
self.isScrollEnabled = isScrollEnabled
self.onPageSelected = onPageSelected
}
public func makeCoordinator() -> Coordinator {
Coordinator(self)
}
public func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
scrollView.isPagingEnabled = true
scrollView.showsHorizontalScrollIndicator = false
scrollView.bounces = true
scrollView.backgroundColor = .clear
scrollView.isScrollEnabled = isScrollEnabled
scrollView.delegate = context.coordinator
// 创建容器视图
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.distribution = .fillEqually
stackView.alignment = .fill
stackView.spacing = 0
stackView.backgroundColor = .clear
stackView.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(stackView)
// 添加约束
NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(
equalTo: scrollView.leadingAnchor
),
stackView.trailingAnchor.constraint(
equalTo: scrollView.trailingAnchor
),
stackView.topAnchor.constraint(equalTo: scrollView.topAnchor),
stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
stackView.heightAnchor.constraint(equalTo: scrollView.heightAnchor),
])
// 保存引用
context.coordinator.stackView = stackView
context.coordinator.scrollView = scrollView
// 添加视图
context.coordinator.setupViews()
return scrollView
}
public func updateUIView(_ scrollView: UIScrollView, context: Context) {
// 检查是否需要更新滚动位置
let width = scrollView.bounds.width
if width > 0 {
let targetOffset = CGFloat(selectedTab.wrappedValue) * width
let currentOffset = scrollView.contentOffset.x
// 只有当差异大于阈值时才滚动(避免浮点误差)
if abs(currentOffset - targetOffset) > 1 && !context.coordinator.isUserDragging {
// 标记为程序化滚动
context.coordinator.isProgrammaticScrolling = true
scrollView.setContentOffset(
CGPoint(x: targetOffset, y: 0),
animated: true
)
}
}
scrollView.isScrollEnabled = isScrollEnabled
}
public class Coordinator: NSObject, UIScrollViewDelegate {
var parent: PagedHorizontalScrollView
var stackView: UIStackView?
var scrollView: UIScrollView?
var hostingControllers: [UIHostingController<AnyView>] = []
// 添加标志来跟踪滚动状态
var isUserDragging = false
var isProgrammaticScrolling = false
var lastReportedPage = -1
init(_ parent: PagedHorizontalScrollView) {
self.parent = parent
self.lastReportedPage = parent.selectedTab.wrappedValue
parent.onPageSelected?(lastReportedPage)
}
func setupViews() {
guard let stackView = stackView, let scrollView = scrollView else {
return
}
// 清除旧的视图
stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
hostingControllers.removeAll()
// 添加新的视图
for view in parent.views {
let hostingController = UIHostingController(rootView: view)
hostingController.view
.translatesAutoresizingMaskIntoConstraints = false
hostingController.view.backgroundColor = .clear
stackView.addArrangedSubview(hostingController.view)
// 设置宽度约束
NSLayoutConstraint.activate([
hostingController.view.widthAnchor.constraint(
equalTo: scrollView.widthAnchor
)
])
hostingControllers.append(hostingController)
}
}
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
// 如果是程序化滚动,不更新 selectedTab
if isProgrammaticScrolling {
return
}
// 只在用户拖动时更新
if isUserDragging {
updateFinalPosition(scrollView)
}
}
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
isUserDragging = true
isProgrammaticScrolling = false
print("User started dragging")
}
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if !decelerate {
isUserDragging = false
// 确保最终位置正确
updateFinalPosition(scrollView)
}
}
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
isUserDragging = false
// 确保最终位置正确
updateFinalPosition(scrollView)
}
public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
isProgrammaticScrolling = false
parent.onPageSelected?(parent.selectedTab.wrappedValue)
// 程序化滚动结束
print("Programmatic scrolling ended")
}
private func updateFinalPosition(_ scrollView: UIScrollView) {
let width = scrollView.bounds.width
guard width > 0 else { return }
let currentPage = Int(round(scrollView.contentOffset.x / width))
if currentPage != lastReportedPage &&
currentPage >= 0 &&
currentPage < parent.views.count {
lastReportedPage = currentPage
parent.selectedTab.wrappedValue = currentPage
parent.onPageSelected?(currentPage)
}
}
}
}
18. 总结
关键要点
-
正确配置基础属性
- 始终设置正确的
contentSize
- 理解
frame、bounds 和 contentOffset 的关系
- 合理使用
contentInset 适配界面
-
性能优化原则
- 实现视图复用机制
- 避免在
scrollViewDidScroll 中执行重操作
- 使用异步加载和预加载策略
- 及时释放不可见视图的资源
-
用户体验优化
- 提供流畅的滚动体验
- 实现合适的加载指示器
- 处理好键盘交互
- 支持下拉刷新等常见交互
-
常见陷阱规避
- Auto Layout 中正确使用
contentLayoutGuide 和 frameLayoutGuide
- 处理好嵌套 ScrollView 的手势冲突
- 注意内存管理,避免循环引用
最佳实践检查清单
- ✅ ContentSize 设置正确
- ✅ 启用了必要的滚动方向
- ✅ 合理设置了弹性效果
- ✅ 实现了必要的代理方法
- ✅ 处理了键盘遮挡问题
- ✅ 优化了滚动性能
- ✅ 支持了旋转和多任务
- ✅ 添加了适当的无障碍支持
相关资源
版本历史
- iOS 2.0: UIScrollView 首次引入
- iOS 3.0: 添加分页支持
- iOS 5.0: 改进缩放功能
- iOS 7.0: 引入
contentInsetAdjustmentBehavior
- iOS 11.0: 添加
contentLayoutGuide 和 frameLayoutGuide
- iOS 13.0: 改进暗黑模式支持
- iOS 15.0: 优化性能和手势识别
本文档持续更新,最后更新时间:2024年
Giscus 未启用:请在
src/site.config.ts中配置 repoId 与 categoryId。