Scrolling bounds, offsets, and insets

I recently got confused while trying to calculate the minimum and maximum contentOffset of a UIScrollView. This came into play while working on a feature that automatically scrolls off-screen content into view when an item is dragged to the top or bottom of the scroll view. I've supported this feature in Taskboard's project view for a while, so I copied that code into my new app. During testing I realized I've had a bug in this code for several years: my max offset calculation was incorrect. The issue was even more pronounced when a non-zero bottom inset was applied to the scroll view.

I started sketching out various configurations of content within a scroll view, considering content insets and situations with less than or more than a full screen of content. It took me too long to figure it out, partly due to a misunderstanding of what the contentInset means.

Some key things to remember when dealing with UIScrollView:

The scroll view bounds and contentOffset are related: setting the content offset is same as adjusting the bounds.origin. In fact, the documentation for UIView mentions adjusting the bounds origin as a way to display a different area of a view.

On the screen, the bounds rectangle represents the same visible portion of the view as its frame rectangle. By default, the origin of the bounds rectangle is set to (0, 0) but you can change this value to display different portions of the view.

Next, keep in mind the essence of the scroll view:

The scroll view must know the size of the content view so it knows when to stop scrolling; by default, it “bounces” back when scrolling exceeds the bounds of the content.

Finally, remember what the contentInset does:

Use this property to add to the scrolling area around the content.

My problem was due to conceptually applying the bottom inset at some point beyond the bottom of the frame even when the content size was much less than the bounds size. I should have gone back to the docs to confirm my understanding.

The minimum content offset is typically 0, which makes sense. If a top inset is applied, this has the effect of permitting additional scrolling up past the minimum, not to exceed the top inset amount. Whether you have a small or large amount of content, the min-y offset is 0 - top.

The maxmimum offset is the amount you'd need to scroll in order to just see the bottom of the content. If your content extends 5pts beyond the bottom of the screen, you'd expect to have to scroll 5pts in order to make it visible. With that in mind, consider the amount of content beyond the visible bounds as: excess = content - visible. If excess is less than zero, it means the content is already visible. Factor in any top or bottom insets by thinking of them as additional regions hugging the top and bottom of the content. The effective content excess is then top + (content - visible) + bottom. If this is negative, everything is visible and the max-y offset is the same as the min-y offset. If the effective excess is greater than or equal to zero, some scrolling is necessary to get to the bottom: that amount is just the excess.

In simplified terms, the min- and max-y offsets are:

min-y: -top
max-y: miny + fmax(0, excess)