iOS: Maintaining Content Offset When The Size Of Your UIScrollView Changes

Kyle Redfearn
Vivint Innovation Center
4 min readAug 7, 2017

--

Hello! I work on mobile application development at Vivint, and recently, I presented a technique I developed while working on the main Vivint Smart Home app that I thought I’d share with you.

Sample code can be found here: KRScrollView

What is this about?

The default behavior of the UIScrollView is to maintain the content offset when the size of your scroll view changes (e.g., orientation change or an animation). One problem with this is that the content offset is relative to the upper left hand corner of the scroll view. The user is not interested in the content in the upper left hand corner, they are interested in the center of the scroll view. Another problem with this behavior is when the size of the scroll view changes, the existing content offset can now lie outside the bounds of the new scroll view’s size. When this happens it is equal to setting an invalid content offset, and UIScrollView automatically resets the content offset to (0,0). I have developed a solution to both of these problems by saving the content offset as a ratio referencing the center of the scroll view.

Content offset as a ratio

class KRScrollView: UIScrollView {
// This is a reference to the scroll view's content view
var contentView: UIView? = nil
// This is the content offset expressed as a ratio between 0
// and 1, from the center, not the top left corner
var contentOffsetRatio = CGPoint(x: 0.5, y: 0.5)

// When the content offset gets set, figure out what the
// ratio is between 0 and 1
override var contentOffset: CGPoint {
didSet {
let width = self.contentSize.width
let height = self.contentSize.height
let halfWidth = self.frame.size.width / 2.0
let halfHeight = self.frame.size.height / 2.0
let centerX = ((self.contentOffset.x + halfWidth)
/ width)
let centerY = ((self.contentOffset.y + halfHeight)
/ height)
self.contentOffsetRatio = CGPoint(
x: centerX, y: centerY)
}
}
}

This UIScrollView subclass allows you to save the content offset as a ratio between 0 and 1 relative to the center of your scroll view. Now when you change your scroll view’s size, you can restore the content offset to the intended location. Setting this new content offset can be tricky because the new content offset can lie outside the bounds of the new scroll view’s size.

To assist in this process I’ve created on KRScrollView the following helper method:

func determineNewContentOffsetForRatio(ratio: CGPoint) {
if var frame = self.contentView?.frame {
// Adjust the frame to be zero based since it can have a
// negative origin
if frame.origin.x < 0 {
frame = CGRectOffset(frame, -frame.origin.x, 0)
}
if frame.origin.y < 0 {
frame = CGRectOffset(frame, 0, -frame.origin.y)
}

// Calculate the new content offset based off the
// contentOffsetRatio
var offsetX = ((ratio.x * self.contentSize.width)
- (self.frame.size.width / 2.0))
var offsetY = ((ratio.y * self.contentSize.height)
- (self.frame.size.height / 2.0))

// Create a field of view rect witch represents where
// the scroll view will positioned with this new
// content offset
var fov = CGRectMake(
offsetX, offsetY,
self.frame.size.width,
self.frame.size.height)
if fov.origin.x < 0 {
fov = CGRectOffset(fov, -fov.origin.x, 0)
}
if fov.origin.y < 0 {
fov = CGRectOffset(fov, 0, -fov.origin.y)
}

// If the new content offset is going to go outside the
// bounds of the new frame, reset
// the x or y coordinate to its maximum value
let intersection = CGRectIntersection(fov, frame)
if !CGSizeEqualToSize(intersection.size, fov.size) {
if (CGRectGetMaxX(fov) > frame.size.width) {
offsetX = frame.size.width - fov.size.width
}
if (CGRectGetMaxY(fov) > frame.size.height) {
offsetY = frame.size.height - fov.size.height
}
}

// Preventing negative content offsets
offsetY = offsetY > 0.0 ? offsetY : 0.0;
offsetX = offsetX > 0.0 ? offsetX : 0.0;
self.contentOffset = CGPointMake(offsetX, offsetY);
}
}

This helper method makes sure the new content offset does not lie outside the bounds of the new scroll view’s size. This is especially helpful during orientation changes. During a portrait orientation, you can pan all the way to the right and upon rotating your device, your existing content offset is now outside the bounds of the scroll view’s new size, you will get reset back to (0,0).

Usage

An example of usage of these properties and methods can be done in UIViewController’s viewWillTransitionToSize:withTransitionCoordinator:

override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator) {
// Save the contentOffsetRatio before we rotate so we can
// properly determine the new content offset
let ratio = self.scrollView.contentOffsetRatio;
coordinator.animateAlongsideTransition({ (context) in
self.scrollView.determineNewContentOffsetForRatio(ratio)
}, completion: nil)
}

You can see that I am saving off the content offset before the animation and I am setting it as part of the animation.

Happy coding! I hope this helps you make a better scroll view experience for your users!

--

--