Saturday, December 31, 2016

Scroll to the top of a UICollectionView when the content changes

I was recently working on an iPad app (using Swift 2) which has a UICollectionView of news articles.  These articles are categorized into channels, which allows the user to choose specific types of news to read.  In this situation we really wanted to scroll to the top of the articles when the user chooses a new channel, otherwise they might miss the articles above where they had scrolled on the previous channel without even realizing it.


So to solve this, I first tried adding code to scroll to the top right after I retrieve the content and call UICollectionView.reloadData().  Unfortunately this didn't turn out as I had hoped.
Since reloadData runs asynchronously, the jump to the top will be attempted before the new content is loaded into the collection. This causes a crash the first time it's called, since there will be no items in the view yet and therefore you can't scroll to the top item.
Ok, so we should probably be doing the scroll in UICollectionViewDelegate's collectionViewDidFinishLoading(UICollectionView) function, right?  Well, unfortunately, that doesn't actually exist.



So, we need to detect when the content has finished loading, and only then scroll to the top.

Here's how I made it work:


1. I created an ("old school" Objective-C style) observer in my controller's viewDidLoad to see that the content had loaded.

...

var observerAdded : Bool = false
private var newsContext = 0

override func viewDidLoad() {
  super.viewDidLoad()

  ...

  if(!self.observerAdded) {
    let opt = NSKeyValueObservingOptions([.New, .Old])
    self.collectionView.addObserver(self,
                                    forKeyPath: "contentSize",
                                    options: opt,
                                    context: &self.newsContext)
    self.observerAdded = true
  }
}

Notes:
  • The reason for observerAdded is that I don't want to add it more than once and I need to know that it should be removed.
  • The reason for myContext is that I need to know this observer callback is for me and not something else iOS or other code is doing.

2. Then I made sure to unregister the observer during cleanup.

deinit {
    if(self.observerAdded) {
        collectionView.removeObserver(self,
                                      forKeyPath: "contentSize",
                                      context: &self.newsContext)
        self.observerAdded = false
    }
}


3. Finally I added the observer callback and scroll to the top item.

override func observeValueForKeyPath(keyPath: String?,
                                     ofObject object: AnyObject?,
                                     change: [String : AnyObject]?,
                                     context: UnsafeMutablePointer<void>) {
    if(context == &self.newsContext) {
        let old = change![NSKeyValueChangeOldKey]?.CGSizeValue()
        let new = change![NSKeyValueChangeNewKey]?.CGSizeValue()
        if(old != new && new?.height > 100) {
            dispatch_async(dispatch_get_main_queue(), {
                self.collectionView.scrollToItemAtIndexPath(NSIndexPath(forItem: 0, inSection: 0),
                                                            atScrollPosition: .Top,
                                                            animated: true)
            })
        }
    } else {
        super.observeValueForKeyPath(keyPath, ofObject: object, change: change, context: context)
    }
}