首页 / 资讯中心 / 文章详情

UIScrollView 滚动核心:三属性、坐标模型与问题排查

UIScrollView 滚动核心:三属性、坐标模型与问题排查 ★ FEATURED ARTICLE
UIScrollView 的 contentSize、contentOffset、contentInset 这三个属性我在带新人的时候几乎每次都要重新讲一遍——不是因为概念难而是因为绝大多数人第一次写滚动视图时都是照着别人的代码抄一遍能跑就行压根没搞明白这三个值之间是怎么互相咬合的。结果一旦遇到内容滚不到底、顶部多出一截空白、下拉刷新之后位置回不去这类问题就只能靠反复试数字来蒙。这篇东西就是把我这些年在这三个属性上踩过的坑、推过的公式、以及可以直接抄的配置整理出来。这三个属性分别管三件事contentSize 决定能滚多远contentOffset 决定现在滚到哪了contentInset 决定内容四周额外留多少空。三者合起来构成一个完整的滚动坐标模型。搞懂模型之后你写滚动视图基本不用再靠试。这篇文章适合刚接触 iOS 界面开发的同学也适合写了几年但一直对为什么内容不足一屏时 offset 是负的这类问题心里没底的老手。我会从概念、公式、实操、排错四个层次往下拆代码用 Swift 写思路对 Objective-C 同样成立。1. 三个属性到底各自管什么事1.1 contentSize滚动内容的画布尺寸contentSize 是一个 CGSize描述的是滚动内容区域的总尺寸注意它描述的是内容不是你看到的那个框。很多人第一次接触会本能地以为scrollView 的 frame 多大contentSize 就该多大这是最典型的误解。frame更准确地说是 bounds是视口是那扇窗户contentSize 是窗户外面的那面墙。墙比窗户大你才能推着窗户在墙上移动。UIScrollView 有个很容易被忽略的特性它不会自动从子视图的布局反推 contentSize。你往里面塞了十个 label排到了 3000 点高只要你没手动设置 contentSize.height它就依然是 CGSizeZero你就一点都滚不动。这一点和 UITableView、UICollectionView 完全不同——后两者会根据数据源自己算。所以纯 UIScrollView 场景下维护 contentSize 是你自己的责任。现代表述方式有两种。第一种是老派的手动赋值在布局完成后计算内容高度直接scrollView.contentSize CGSize(width: bounds.width, height: totalHeight)。第二种是 iOS 11 之后推荐的 Auto Layout 方案把内容容器视图的四条边约束到contentLayoutGuide把容器的宽度约束到frameLayoutGuide.width纵向滚动时宽度锁死避免横向漂移高度不约束死让内部子视图的约束链把它撑开系统会自动把内容容器的尺寸推导成 contentSize。这套方案的优点是内容变化时 contentSize 自动跟着变不用手动重算代价是约束一旦有冲突或者某个子视图高度不确定contentSize 就会算错而且报错往往在运行时才暴露排查起来比手动赋值麻烦。1.2 contentOffset视口在画布上的坐标原点contentOffset 是一个 CGPoint表示内容坐标系的原点相对于滚动视图 bounds 原点的偏移量。用人话说它记录的是当前视口左上角对着内容里的哪个点。内容整体往上推、你看到下面的部分时contentOffset.y 变大内容往右推、你看到右边的部分时contentOffset.x 变大。方向和直觉是一致的offset 增大 内容向负方向移动 你看到了更靠后的内容。这里有个新手最容易懵的点contentOffset 的默认值不是 (0, 0) 就完事了它的静止值取决于 contentInset。当 contentInset.top 是 20 时滚动视图停在最顶部时 contentOffset.y 是 -20不是 0。第一次在scrollViewDidScroll里打印出 -20 的人经常会以为哪里算错了其实这是完全正确的行为。因为 contentInset 相当于给内容上方垫了一块 20 点高的空白你要看到内容的第 0 行视口原点就得往上挪 20 点也就是 -20。contentOffset 还有一个隐蔽但重要的行为差异直接给contentOffset赋值会立即生效并且会触发一次scrollViewDidScroll回调用setContentOffset(_:animated:)走系统动画动画过程中每一帧都会触发scrollViewDidScroll动画结束还会额外触发scrollViewDidEndScrollingAnimation。这两个回调的差异在做滚动到指定位置后执行某个动作时非常关键——如果你只监听了scrollViewDidScroll手动赋值和动画赋值都会进来很容易重复触发如果你在等动画结束就必须依赖scrollViewDidEndScrollingAnimation而手动赋值永远不会走这个回调。1.3 contentInset给内容四周留出的安全边距contentInset 是 UIEdgeInsets给内容四周加一圈额外的空白。它不改变内容本身的尺寸改变的是可滚动区域的范围以及滚动停止时的归位位置。最典型的用途就是自定义下拉刷新把手势往下拉一段距离后把 contentInset.top 从 0 改成 60滚动视图就会稳定停在 -60 的位置正好露出顶部的刷新指示器刷新结束后再把 inset 改回 0视图自动回到原位。从 iOS 11 开始inset 的计算多了一层。系统实际生效的是adjustedContentInset它等于你自己设置的 contentInset 再叠加安全区safeAreaInsets叠加规则由contentInsetAdjustmentBehavior这个枚举控制。四个取值的行为差异值得记牢取值行为适用场景.automatic默认值纵向滚动且是第一个可滚动视图时自动叠加安全区大多数普通页面.scrollableAxes只在可滚动轴向上叠加安全区横向分页且需要贴边.never完全不叠加安全区需要精确控制、或自己处理安全区.always总是叠加安全区不考虑是否首个滚动视图嵌套滚动、特殊布局我自己踩过的坑是全屏页面里 scrollView 顶部内容莫名其妙多出 44 点空白查了半天以为是约束写错最后发现是.automatic自动叠加了导航栏的安全区。这类问题在.never一改之后立刻消失。所以我的习惯是凡是需要精确控制顶部位置的场景一律显式设成.never然后自己用 contentInset 加需要的那部分行为完全可控。1.4 三者的咬合关系这三个值不是独立的它们共同决定了 contentOffset 的合法区间。你可以这么理解contentSize 定义了画布多大contentInset 定义了画布外面还允许多看出去多少contentOffset 是在这个允许范围内自由移动的坐标。任何一次滚不到底、顶部弹不回来、停的位置差一点的问题本质上都是这三个值里至少有一个算错了。举个具体的例子。假设视口高度 bounds.height 600内容高度 contentSize.height 1000inset 上下各 20。那么 contentOffset.y 的合法区间是 [-20, 1000 20 - 600]也就是 [-20, 420]。往回推导当 offset -20 时视口顶部对着内容坐标系里的 0正好是内容最顶端当 offset 420 时视口底部对着内容坐标系里的 420 600 - 20 1000正好是内容最底端。这套推导你要是能手推一遍80% 的滚动位置问题就能自己算出来。2. 坐标系与计算模型把公式推到能背下来2.1 从 contentOffset 反推当前看到的内容区间这是最实用的一条公式。当前视口内可见的内容在内容坐标系里的范围是可见内容顶部 contentOffset.y adjustedContentInset.top 可见内容底部 contentOffset.y bounds.height - adjustedContentInset.bottom 可见内容高度 bounds.height - adjustedContentInset.top - adjustedContentInset.bottom横轴同理。这个公式我几乎每个项目都会用一次最常见的地方是做滚动到可视区域功能——你想让某个子视图假设它在内容坐标系里的 y 是 targetY高 h完整露出来那么需要满足可见内容顶部 targetY 可见内容底部 targetY h把公式代进去解不等式就得到 contentOffset.y 的目标区间。很多人写滚动到某个元素直接用scrollView.scrollRectToVisible那个方法确实方便但它内部算的是不含 inset 的原始矩形在设置了非零 inset 或者有安全区叠加的情况下经常会出现滚过去了但顶部被遮住一半的现象。自己按公式算一遍再调用setContentOffset(_:animated:)位置就会精确得多。这里还需要特别留意 zoomScale。如果你的滚动视图是支持缩放的内容的实际显示尺寸是 contentSize 乘以 zoomScalecontentOffset 的坐标系也被缩放影响过公式要改成按缩放后的坐标系处理。这条我建议新手直接避开缩放功能等基础模型吃透了再碰。2.2 可滚动区间的完整推导contentOffset 的合法区间分两种情形必须分开讨论因为代码里经常需要判断现在到底还能不能再滚。情形一内容加上 inset 之后超过了视口也就是contentSize.height adjustedContentInset.top adjustedContentInset.bottom bounds.height。此时最小 offset -adjustedContentInset.top 最大 offset contentSize.height adjustedContentInset.bottom - bounds.height情形二内容加上 inset 也没超过视口。此时内容整体是撑不满的滚动视图没有可滚动空间offset 会固定停在-adjustedContentInset.top。注意这种情况下如果你开了 bounces用户依然可以拉出弹簧效果但松手后会回到这个固定值。这个细节解释了另一个常见困惑内容很少的页面为什么拉一下会弹回去而且回弹位置总在顶部——因为它没有可滚区间只有归位点。在上面两种情形下contentOffset在用户拖动过程中都可能暂时超出合法区间这是因为 bounces 允许越界拖拽。如果你在scrollViewDidScroll里做位置判断一定要意识到拿到的 offset 可能是越界的不要直接用去卡边界否则回弹过程中会出现界面元素闪烁。稳妥的做法是先把 offset 夹到合法区间里再参与计算也就是写一个 clamp 函数。2.3 bounces 与 alwaysBounceVertical 的作用边界bounces 默认是 true但它只在内容确实超出视口时才生效。内容不足一屏时想让它依然有下拉弹簧就必须打开alwaysBounceVertical true横向对应 alwaysBounceHorizontal。这个开关在有下拉刷新的页面上基本是必开的否则用户会觉得这个页面拖不动、很死板。另外一个容易被忽略的是scrollView.isScrollEnabled和scrollView.isUserInteractionEnabled的区别。前者关掉之后内容不会响应拖拽但 contentOffset 依然可以通过代码修改后者关掉则是整个视图不接收触摸事件连子视图的点击都会失效。排查手指拖不动但按钮能点这类问题时先看前者排查整个区域都点不动时先看后者。3. 实操搭一个能验证的滚动容器3.1 初始化与基础配置清单我一般会把这套配置固化成一个工厂方法避免每次新建都漏掉某几项。下面这段是我实际项目中常用的形态纵向滚动的通用模板let scrollView UIScrollView() scrollView.translatesAutoresizingMaskIntoConstraints false // 关掉系统自动叠加位置完全自己控制 scrollView.contentInsetAdjustmentBehavior .never // 内容不足一屏时也要有弹簧手感 scrollView.alwaysBounceVertical true // 隐藏滚动条视觉更干净按需 scrollView.showsVerticalScrollIndicator false // 减速速率默认 .normal信息流页面常用 .fast scrollView.decelerationRate .normal // 键盘躲避能力已经不需要了系统早就不维护 // 内边距初始值 scrollView.contentInset UIEdgeInsets(top: 0, left: 0, bottom: 24, right: 0)几处值得说明的取舍。contentInsetAdjustmentBehavior .never是我个人的强偏好理由前面说过行为可预测。bottom加 24 是为了给底部留呼吸空间也方便和悬浮按钮错开具体数值按设计稿来但这个思路要有——很多页面底部内容被 Home 指示条压住的根本原因就是没有留底部 inset。decelerationRate在信息流类页面调成.fast会让翻页手感更利落在相册这类需要精细浏览的场景则保持.normal更合适。3.2 contentSize 的两种维护方式与选择手动方式适合内容结构简单、高度可预先计算的场景。做法是在布局完成的回调里统一算一次并在内容变化时重新调用private func updateContentSize() { let width scrollView.bounds.width let height contentContainer.systemLayoutSizeFitting( CGSize(width: width, height: UIView.layoutFittingCompressedSize.height), withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel ).height scrollView.contentSize CGSize(width: width, height: height) }这里面systemLayoutSizeFitting的两个优先级参数是关键横向必须是.required因为宽度已经被外层锁死了纵向给.fittingSizeLevel才会返回压缩后的真实高度。如果两个都给.required遇到内部有可伸缩空间时会返回一个不符合预期的大值我见过有人因此得到高度等于屏幕高度、怎么滚都滚不动的诡异结果。Auto Layout 方式更省心但约束要写对。核心是三件事容器视图四边贴contentLayoutGuide容器宽度等于frameLayoutGuide.width纵向滚动时容器内部的约束链从上到下完整闭合最后一条子视图的底部约束贴在容器底部。三条缺一不可少了第一条内容不参与滚动少了第二条宽度会漂移少了第三条高度无法推导。这种做法在内容会动态增删时优势明显因为系统会自动重算不需要你手动触发。3.3 自定义下拉刷新时的 inset 手动管理这部分是 contentInset 最经典的应用场景值得完整写一遍。核心思路是把 inset.top 当作一个状态量来管理而不是直接改 contentOffset。private let refreshTriggerHeight: CGFloat 64 private var isRefreshing false func scrollViewDidScroll(_ scrollView: UIScrollView) { guard !isRefreshing else { return } let pullDistance -(scrollView.contentOffset.y scrollView.adjustedContentInset.top) if pullDistance refreshTriggerHeight { beginRefresh() } } private func beginRefresh() { isRefreshing true var inset scrollView.contentInset inset.top refreshTriggerHeight UIView.animate(withDuration: 0.25) { self.scrollView.contentInset inset self.scrollView.setContentOffset( CGPoint(x: 0, y: -self.refreshTriggerHeight), animated: false ) } } private func endRefresh() { isRefreshing false var inset scrollView.contentInset inset.top 0 UIView.animate(withDuration: 0.25) { self.scrollView.contentInset inset self.scrollView.contentOffset .zero } }这段代码里有三个细节是我踩坑之后才加上的。第一pullDistance的计算必须用adjustedContentInset.top而不是contentInset.top否则在有安全区的设备上触发阈值会偏移。第二isRefreshing这个锁必不可少因为scrollViewDidScroll在拖拽过程中会被高频调用没加锁的话一次下拉会触发十几次开始的逻辑。第三结束刷新时先改 inset 再改 offset并且用同一个动画块包住顺序反了会出现内容先回弹、再跳一下的视觉抖动。注意修改 contentInset 会改变 contentOffset 的合法区间如果此时用户还在拖拽中可能出现位置突变。稳妥的写法是在scrollViewWillBeginDragging里记下当前状态而在scrollViewDidEndDragging/scrollViewDidEndDecelerating之后再执行 inset 变更。3.4 分页滚动的偏移计算与 targetContentOffset 重写横向分页是个高频需求很多人第一反应是设置isPagingEnabled true就完事。它确实能工作但只适合页宽恰好等于视口宽这一种情况。一旦你要做的是露出左右相邻页一小部分的效果就必须关掉内建分页自己在scrollViewWillEndDragging里改targetContentOffsetfunc scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointerCGPoint) { let pageWidth itemWidth spacing let rawPage (targetContentOffset.pointee.x scrollView.contentInset.left) / pageWidth var page: CGFloat if velocity.x 0 { // 没有速度时按最近页吸附 page rawPage.rounded() } else { // 有速度时按方向吸附手感更跟手 page velocity.x 0 ? ceil(rawPage) : floor(rawPage) } page max(0, min(page, CGFloat(itemCount - 1))) let targetX page * pageWidth - scrollView.contentInset.left targetContentOffset.pointee CGPoint(x: targetX, y: 0) }这里的 contentInset.left和- contentInset.left是一对不能只写一边否则有左 inset 时整体会偏移半个页宽。velocity.x 0的分支同样重要用户缓慢拖动后松手速度可能接近零这时候按最近页吸附比按方向吸附更符合直觉也是系统相册类应用的实际行为。最后的 clamp 是防止越界尤其在数据源刚刷新、itemCount 变小的瞬间不加 clamp 会出现滚到空白区域的情况。再补充一点关于isPagingEnabled的坑它和contentInset一起使用时系统的分页基准点是不考虑 inset 的所以你会看到第一页和最后一页的停靠位置差一截。这个问题在带左右边距的横向卡片列表里特别常见解决办法就是上面这样关掉内建分页、自己接管。4. 常见问题与排查实录4.1 完全滚不动先查 contentSize 再看 bounces这是出现频率最高的问题。排查顺序我固化成三步。第一步打印contentSize和bounds.size如果 contentSize 是 zero 或者小于 bounds那就是内容尺寸压根没设上。第二步如果是 Auto Layout 方案检查容器视图的四边约束和宽度约束是否齐全特别是最后一条子视图的底部约束有没有贴到容器底部——这条最容易漏漏了之后约束链断裂系统会推导出一个比较随意的高度。第三步检查是否有兄弟视图的约束把 scrollView 的高度压成了零这种情况下 contentSize 设置得再大也没用因为视口本身没高度。还有一种隐蔽情况你在viewDidLoad里设置 contentSize但此时布局还没完成bounds 是初始值等到真正布局后 contentSize 和实际内容对不上。稳妥做法是把依赖布局的计算放到viewDidLayoutSubviews里并且加一个尺寸变化检测避免每次布局都重复计算造成抖动。4.2 初始偏移异常顶部凭空多出一截空白前面提过这类问题的第一嫌疑人是contentInsetAdjustmentBehavior的自动叠加。排查方法是打印contentInset和adjustedContentInset两个值做对比如果后者明显大于前者就说明系统叠加了安全区。处理方式要么改成.never自己控制要么就是接受它然后把视觉上多出来的那部分在设计上消化掉。第二种可能是automaticallyAdjustsScrollViewInsets这类历史遗留属性的影响虽然早已废弃但在一些老代码里还能看到痕迹混用新旧 API 会产生双重调整。遇到无法解释的偏移我会先全局搜索一下这个关键词确认没有残留。第三种可能和 inset 的动画有关。如果上一次刷新结束后 inset 的复位动画被打断比如页面被 pop 或者切了 Tabinset 会残留成非零值下次进来就带着偏移。这种情况需要在页面出现时主动复位一次 inset把状态归零别指望它自己恢复。4.3 嵌套滚动与手势冲突的处理顺序scrollView 套 scrollView 是需求里常见的结构比如外层纵向、内层横向或者外层页面滚动、内层是地图、轮播这类自带手势的控件。冲突的根源是手势识别器会互相抢。处理原则有三条内层横向滚动和外层纵向滚动天然不冲突因为方向不同系统能正确分配同方向嵌套必须靠gestureRecognizerShouldBegin或者UIGestureRecognizerDelegate的shouldRecognizeSimultaneouslyWith来显式协调内层是地图、绘图这类需要独占手势的控件时要给它加一个透明蒙层等到用户明确点击激活后再撤掉蒙层。我个人的经验是同方向嵌套能不做就不做。它带来的体验问题远大于实现成本用户分不清当前拖的是哪一层滚动惯性也会互相打断。真有这种需求通常意味着信息架构该重新设计了。4.4 常见问题速查表现象最可能原因快速验证处理方式完全滚不动contentSize 为零或过小打印 contentSize 与 bounds补内容尺寸或修正约束链滚到底部差一截少了底部 inset 或最后一条约束没闭合对比 maxOffset 与实际停止位置补 contentInset.bottom 或补约束顶部多出空白安全区自动叠加对比 contentInset 与 adjustedContentInset设.never或调整设计下拉刷新回不去结束刷新时 inset 未复位打印刷新后的 contentInset显式复位 inset 并加动画分页停靠偏移内建分页不认 inset检查首尾页停靠位置关内建分页重写 targetContentOffset滚动时子视图闪烁越界 offset 直接参与计算在 didScroll 中打印 offset 范围用 clamp 夹到合法区间滚动回调被高频触发未做节流统计每秒回调次数加时间戳或距离阈值节流提示这张表建议直接存进笔记遇到问题先对号入座比漫无目的地在代码里翻要快得多。绝大多数滚动问题都能在三分钟内定位到类别。5. 进阶动画、监听与性能5.1 setContentOffset 动画的正确使用姿势系统提供的setContentOffset(_:animated:)是黑盒动画时长和曲线都不可调。这在需要和页面其他动画同步的场景下会很难受。我一般的处理是简单的位置跳转直接用系统方法省事需要和转场动画、内容渐显等配合时用UIView.animate包住非动画版本的赋值UIView.animate(withDuration: 0.35, delay: 0, options: [.curveEaseInOut], animations: { self.scrollView.contentOffset CGPoint(x: 0, y: targetY) })这种写法有个注意点动画过程中scrollViewDidScroll会不会回调取决于动画的执行方式不同系统版本表现可能不一致。所以如果你有依赖滚动位置的 UI 更新逻辑比如导航栏透明度不要假设它一定会被调用在动画的 completion 里再手动同步一次状态是更稳妥的做法。另一个坑是动画冲突。如果用户在一次程序化滚动动画还没结束时就开始手动拖拽两股力量会打架表现为内容抖动或者突然跳到某个位置。处理方式是在scrollViewWillBeginDragging里调用scrollView.layer.removeAllAnimations()中断掉进行中的动画把控制权干净地交还给用户。5.2 滚动监听的节流与状态去重scrollViewDidScroll的调用频率和屏幕刷新率挂钩在高刷设备上能到每秒上百次。在里面做重计算或者触发网络请求很快就会出现掉帧和重复请求。我常用的组合是距离阈值 状态标记private var lastReportedOffsetY: CGFloat -.greatestFiniteMagnitude func scrollViewDidScroll(_ scrollView: UIScrollView) { let y scrollView.contentOffset.y // 距离阈值变化超过 8 点才处理 guard abs(y - lastReportedOffsetY) 8 else { return } lastReportedOffsetY y // 再做具体的 UI 更新 updateNavigationBarAlpha(with: y) }8 点这个阈值不是拍脑袋的它大致对应导航栏透明度从 0 变到 1 过程中一个肉眼可辨的最小变化量。阈值太小等于没节流太大则会看出明显的阶梯感实际调试时在 4 到 12 之间取一个顺眼的即可。还有一个容易被忽略的点是状态去重。很多逻辑比如滚动到某个位置时加载下一页如果不做状态标记会在同一个区间内被反复触发。加一个hasLoadedNextPage之类的布尔开关配合请求完成后的重置是最省事也最可靠的做法。5.3 大量子视图下的布局策略内容很长、子视图很多时一次性把所有视图都加到 scrollView 上是最直接但也最笨的做法。内存和布局计算都会吃不消滑到后面明显卡顿。这种情况下的优化方向有三个层次。第一层是复用。如果内容是列表形态直接换 UITableView 或 UICollectionView它们本来就是为了解决这个问题设计的cell 复用、按需布局都是现成的。这是最省力也最正确的选择我很少见到有人真的必须在纯 scrollView 上硬扛长列表。第二层是分块布局。内容是复杂表单、图文混排这种不方便用列表结构的场景可以把内容按视觉区块拆成若干子容器只对进入可视范围前后各一屏的区块做实际布局其余区块用一个等高占位视图代替。这么做代码量会上去但收益很明显尤其是首屏渲染时间。第三层是预计算高度。如果每个区块的高度都能提前算出来比如纯文本按行数换算那就把所有高度先算一遍缓存起来直接把总和设成 contentSize占位视图直接用缓存高度。这样连测量这一步都省了滚动过程只做视图的增减性能最好代价是高度的计算逻辑必须足够准确一旦算错就会出现滚动条跳动。这三层不是互斥的实际项目里经常是混用。比如一个详情页整体是 scrollView里面的评论区长列表用 collectionView评论区之外的部分用分块布局。分层的原则很简单能用系统控件的地方别自己造必须自己造的才去抠性能。5.4 我个人的一点经验写滚动视图这么多年最大的感受是几乎所有看起来像是渲染 bug的滚动问题根子都在三个数值算错了或者被系统悄悄改过。所以我的调试习惯是一旦滚动行为不符合预期先别改代码先在scrollViewDidScroll里把 contentOffset、contentSize、contentInset、adjustedContentInset、bounds 这五个值一起打出来滑一遍看变化规律。大多数时候看完这一屏日志问题在哪儿就很清楚了。另外一条是在做任何涉及内容尺寸变更的操作增删条目、展开折叠、字体切换时一定记得同步更新 contentSize并且考虑要不要顺带修正 contentOffset。内容变短了但 offset 还停在旧位置就会出现内容不见了、实际是滚到了空白区域的假象。这种问题在日志里看 offset 超出 maxOffset 就一目了然但如果不知道去查往往要折腾很久。
阅读完成 · 觉得有帮助?
咨询建站