Game Diary: Infinite Scroll Views and Sprite Kit

The game I am developing has two principle modes: build, and run. When a player is in build mode, they can scroll around the world. The world is conceptually infinite, and things may be anywhere. My trouble was how I should implement this infinite scrolling.

I was certain that I wanted to use UIScrollView for this. While I could have interpreted touches and done something custom, but that approach would never exactly match the behaviour that iOS users are accustomed to, which can be disconcerting. Convincing UIScrollView to do this for me was not so simple, so I'm recording it here.

Some Googling led me to session 104 at WWDC 2011, in which they discuss an infinite scroll view. The idea is that as we scroll, the view moves. Some time before it gets to the edge of the scroll view, we set the offset to be in the middle, and we move the content so that we don't see the shift. This is done inside layoutSubviews, and the logic is uninteresting. It's a lot of adding things together, but it's all neatly handled inside a small child of UIScrollView called InfiniteScrollView.

This seemed just what I was after, but it's not quite. The problem is that my content is an SKView, and the thing I want to move is an SKNode inside an SKScene inside the SKView. So the intended use case is not really applicable. I don't have overlapping content, I have actually really infinite generated content. It just goes on until CGFloat runs out of space.

To remedy this, I tried placing the SKView inside the UIScrollView, and making it larger than the screen, so that some natural scrolling happens. Then, when we overlap, I will send a message down to my root node, telling it how much it needs to update its position by.

UIScrollView has a delegate that gets called when something happens, and this is why. Here we have an almost textbook use case for it.

The trouble with this approach is that when the InfiniteScrollView jumps back to the middle and tells the SKNode to move, the movement doesn't happen until the next frame. So everything disappears, and then comes back. As far as I can tell, there is no way to fix this, since Sprite Kit has its own rendering loop which is unrelated to what the rest of the OS is doing, and it moves things on that schedule, not anybody else's.

The final inspiration came from this year's UIScrollView talk, where they disable userInteraction on the UIScrollView, and move the panGestureRecognizer to a different view, which does have userInteraction turned on. Now, when you pan that view, the UIScrollView will scroll. This technique is far smarter than I am making it out to be, and the entire video is great. It's possibly my favourite talk from this year, which is saying something.

It took me a while to figure out how this might help me. Then suddenly, I had it. The solution is this:

  1. Have an InfiniteScrollView somewhere accessible to your SKScene. I put it as a sibling view to the SKView, but I'm considering moving it elsewhere, and abstracting the behaviour away somewhere. I'll update, but it doesn't really matter.
  2. If the InfiniteScrollView is visible somewhere, set userInteractionEnabled to false.
  3. Move the panGestureRecognizer from the InfiniteScrollView, and add it to the SKView
  4. In your SKScene's update() method, set the root SKNode's position to the total offset of the InfiniteScrollView. Note that since UIKit and Sprite Kit disagree about which way is up, there is some calculation that has to be done.
  5. Observe your buttery scrolling, courtesy of UIScrollView.

Doing this requires a few things to be added to InfiniteScrollView, in particular, the ability to work out the total offset, since the native offset gets reset every time we loop around. To do this, I kept track of the number of times we looped around:


/**
When we loop around, change this.

Increment for moving in positive direction (up
or right), and decrement for negative direction.
*/
var numberOfTimesLoopedAround = CGPointZero
    
/**
Offset that we reset at.
*/
let maxOffsetBeforeLooping: CGFloat = 1000

/**
Offset that represents the middle of the scrollView
*/
var centerOffset: CGPoint {
    let centerOffsetSize =
    	(self.contentSize - self.bounds.size) / 2

    let centerOffset = CGPointMake(
    	centerOffsetSize.width,
        centerOffsetSize.height)

    return centerOffset
}

var offsetRelativeToCenter: CGPoint {
    return self
    	.contentOffset
        .relativeTo(self.centerOffset)
}

None of the above is particularly exciting, since it doesn't really do anything. The umph of this technique is given in the recenterIfNecessary method, which is called by layoutSubviews, which is called every time anything happens.


func recenterIfNecessary() {
    let offset = self.offsetRelativeToCenter

    if offset.x > self.maxOffsetBeforeLooping {
        self.numberOfTimesLoopedAround.x += 1
        self.contentOffset.x -=
        	self.maxOffsetBeforeLooping
    } else if offset.x < -self.maxOffsetBeforeLooping {
        self.numberOfTimesLoopedAround.x -= 1
        self.contentOffset.x +=
        	self.maxOffsetBeforeLooping
    }

    if offset.y > self.maxOffsetBeforeLooping {
        self.numberOfTimesLoopedAround.y += 1
        self.contentOffset.y -=
        	self.maxOffsetBeforeLooping
    } else if offset.y < -self.maxOffsetBeforeLooping {
        self.numberOfTimesLoopedAround.y -= 1
        self.contentOffset.y +=
        	self.maxOffsetBeforeLooping
    }
}

So, this method is responsible for making sure we never bounce against the edge of the scroll view, in all directions. Then, in the SKScene's update method:


let position = scrollView
    .totalOffsetRelativeToCenter
    .asSpriteKitCoordinates
self.world.position = -position

So this happens every frame. We don't rely on the InfiniteScrollView to tell us when it moves, we just ask it. Then we update when it suits us, and we get no lag.

totalOffsetRelativeToCenter is a custom property I have defined on InfiniteScrollView:


var totalOffsetRelativeToCenter: CGPoint {
    return self.numberOfTimesLoopedAround *
    	self.maxOffsetBeforeLooping +
        self.contentOffset
}

It might look like that code is incorrect. How on Earth is self.contentOffset the right thing to ask for, when we have self.offsetRelativeToCenter. The answer is to do with Auto Layout. The InfiniteScrollView is created in a Storyboard, and so its size varies between the point of initialisation and being visible on the screen. Hence, the central point of the view changes between initialisation and being visible.

But, if we don't move the offset to the centre (the actual centre, not what it thinks the centre is before it's visible) before layoutSubviews is called, then its offset is changed and we loop around, since (0, 0) relative to the centre of the scroll view is more than the threshold for looping.

This means that when totalOffsetRelativeToCenter is called, self.numberOfTimesLoopedAround is already nonzero. And it perfectly cancels out the stuff we didn't take care of in self.contentOffset. This is messy, and it feels like an accident, but I couldn't think of a better way of getting around this problem. If anyone does, let me know, because that would be great.