How does UIScrollView scroll, how do you convert points between views inside it, and does it clamp the content offset?
Predict first
What happens to bounds, converted points and an out-of-range offset?
import UIKit
let root = UIView(frame: CGRect(x: 0, y: 0, width: 400, height: 800))
let sc = UIScrollView(frame: CGRect(x: 50, y: 100, width: 300, height: 400))
sc.contentSize = CGSize(width: 300, height: 2000)
root.addSubview(sc)
let child = UIView(frame: CGRect(x: 10, y: 1000, width: 50, height: 50))
sc.addSubview(child)
print(child.convert(CGPoint.zero, to: root))
sc.contentOffset = CGPoint(x: 0, y: 900)
print(sc.bounds.origin, sc.frame.origin)
print(child.convert(CGPoint.zero, to: root))
sc.setContentOffset(CGPoint(x: 0, y: 5000), animated: false)
print(sc.contentOffset)
- (60.0, 1100.0) / (0.0, 900.0) (50.0, 100.0) / (60.0, 200.0) / (0.0, 5000.0)
- (60.0, 1100.0) / (0.0, 0.0) (50.0, -800.0) / (60.0, 1100.0) / (0.0, 1600.0)
- (10.0, 1000.0) / (0.0, 900.0) (50.0, 100.0) / (10.0, 100.0) / (0.0, 1600.0)
Mental model. A scroll view is a window sliding over a long painting: nothing in the painting moves, the window's own coordinate origin does, and nothing stops you sliding it past the edge.
A scroll view does not move its subviews; it changes its own bounds.origin, and contentOffset is that origin. After setting the offset to 900 the check shows bounds.origin = (0, 900) while frame.origin is unchanged (Q239), and a child at y 1000 in content coordinates now converts to y 200 in the parent: convert(_:to:) and convert(_:from:) account for the offset, any ancestor frame offsets and transforms, so a touch point, a button frame or a popover anchor is expressed in whichever view you ask for. Converting to nil means window coordinates, which only works once the view is in a window.
The scroll view does not clamp programmatic offsets: setContentOffset with 5000 on 2000 points of content leaves the offset at 5000 and shows empty space; the clamping you see while dragging comes from the pan gesture's bounce logic. Compute the legal range yourself: minimum -adjustedContentInset.top, maximum contentSize.height - bounds.height + adjustedContentInset.bottom. adjustedContentInset is your contentInset plus the safe-area and bar insets the system adds (contentInsetAdjustmentBehavior), so a hand-written limit that ignores it is off by the navigation bar. With Auto Layout, pin content to contentLayoutGuide to define the scrollable size and to frameLayoutGuide to define the visible size.
Scrolling edits bounds.origin, convert accounts for it, and the offset is not clamped.
Common traps
- Computing positions from
frameafter scrolling. Convert points; the offset is inbounds. - Assuming
contentOffsetis clamped. Programmatic values are not.
They might follow up with
- Why is the initial offset not 0 under a navigation bar? The adjusted inset makes the top-most content sit at
-adjustedContentInset.top. - How do I scroll to a subview?
scrollRectToVisible(_:animated:)with the subview frame converted into the scroll view.
Related questions
- 7.2.2 What is the difference between frame and bounds, and what happens to them under a transform?
- 7.7.2 What is the safe area, and how do you keep content clear of the notch, home indicator and keyboard?
← What does a UIStackView own, and what is the difference between… What is UIButton.Configuration, and when does its… →