How does convert(_:to:) work with a changed bounds origin or a transform, and how does a scroll view scroll?
Predict first
Where does the inner view sit in the window after the parent’s bounds origin changes, then after it is scaled? And what does a scroll view do to bounds?
import UIKit
let win = UIWindow(frame: CGRect(x: 0, y: 0, width: 300, height: 600)); win.isHidden = false
let outer = UIView(frame: CGRect(x: 50, y: 100, width: 200, height: 200)); win.addSubview(outer)
let inner = UIView(frame: CGRect(x: 20, y: 30, width: 100, height: 100)); outer.addSubview(inner)
print(inner.convert(CGPoint.zero, to: win))
outer.bounds.origin = CGPoint(x: 15, y: 25)
print(inner.frame.origin, inner.convert(CGPoint.zero, to: win))
outer.transform = CGAffineTransform(scaleX: 2, y: 2)
print(inner.convert(CGPoint.zero, to: win), outer.frame)
let sv = UIScrollView(frame: CGRect(x: 10, y: 20, width: 100, height: 100)); sv.contentSize = CGSize(width: 100, height: 1000)
sv.contentOffset = CGPoint(x: 0, y: 120)
print(sv.bounds.origin, sv.frame.origin)
- (70, 130), then (20, 30) (55, 105), then (-40, 10) (-50, 0, 400, 400), then (0, 120) (10, 20)
- (70, 130), then (20, 30) (70, 130), then (-40, 10) (-50, 0, 400, 400), then (0, 120) (10, 140)
- (70, 130), then (35, 55) (55, 105), then (-20, 10) (50, 100, 200, 200), then (0, 0) (10, 20)
Mental model. Each parent is a sheet of tracing paper pinned over the one below; sliding it (bounds origin) or enlarging it (transform) moves everything drawn on it, and convert re-reads the point through every sheet.
Each view’s frame is expressed in its superview’s coordinates, and its subviews are placed in its own bounds coordinates. convert(_:to:) and convert(_:from:) walk up and down the tree applying every ancestor’s bounds origin and transform, so it stays correct when those are non-trivial; passing nil means the window’s coordinates. Changing bounds.origin does not move the view: it shifts the coordinate system its children are drawn in. A child at frame origin (20, 30) in a parent whose bounds origin is (15, 25) is still at (20, 30) in the parent’s space but appears 15 and 25 points up-left of where it was, at (55, 105) in the window for a parent at (50, 100). That is the whole mechanism of UIScrollView: contentOffset is bounds.origin, as the check shows (offset (0, 120) reads back as bounds origin (0, 120) while the frame stays put).
Now add a transform: scale the parent by 2 about its centre and the same child lands at (−40, 10) in the window, which you could not get by adding frame origins. This is why you never add frame.origins up the tree by hand: use convert, and use it again when you need the window position of a tapped cell or a point to anchor a popover to. For layers the equivalents are CALayer.convert(_:to:), which also honours 3D transforms, and CALayer.presentation() for the on-screen position mid-animation.
The child’s frame never changes, but the shifted bounds origin and the scale are applied by convert. Scrolling changes only the bounds origin.
Common traps
- Summing
frame.originvalues to get a window position. It ignores bounds origins and transforms; useconvert. - Reading a frame of a transformed view. It is the bounding box of the shape; use
centerandbounds.
They might follow up with
- How do you get a cell’s position in the window?
cell.convert(cell.bounds, to: nil). - What is
contentOffset? The scroll view’sbounds.origin.