If you have an iOS app with multiple child view controllers, it can be annoying when their layout state is lost when switching between them. Scroll View and Web View state are tricky to persist, but we can instead cache the entire view controller with NSCache.
0: Creating a Master-Detail app
For this we will use Xcode's starter template, but with a text view to show the scroll state.
Our setup has a few tweaks from the original: a scroll view to have something to cache, and a storyboard id for the DetailViewController.
1: Show Detail Programmatically
The template uses a storyboard segue to create the detail view controller. Were going to have to start creating it ourselves if we want to start loading from a cache later on. Replace the "Show Detail" segue and present a DetailViewController programmatically in the MasterViewController:
class MasterViewController: UITableViewController {
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
if let indexPath = tableView.indexPathForSelectedRow {
let object = objects[indexPath.row] as! NSDate
let controller = (storyboard?.instantiateViewControllerWithIdentifier("DetailViewController"))! as! DetailViewController
controller.detailItem = object
controller.navigationItem.leftBarButtonItem = self.splitViewController?.displayModeButtonItem()
controller.navigationItem.leftItemsSupplementBackButton = true
navigationController!.popToRootViewControllerAnimated(false)
navigationController!.pushViewController(controller, animated: true)
}
}
...
}
2: Add the Cache
By adding an NSCache to our MasterViewController instance, we can load DetailViewControllers from the cache if they already exist and fall back to our existing instantiateViewControllerWithIdentifier
if they don't.
class MasterViewController: UITableViewController {
let cache = NSCache()
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
if let indexPath = tableView.indexPathForSelectedRow {
let object = objects[indexPath.row] as! NSDate
let controller = cache.objectForKey(object) as? DetailViewController ?? (storyboard?.instantiateViewControllerWithIdentifier("DetailViewController"))! as! DetailViewController
cache.setObject(controller, forKey: object)
controller.detailItem = object
controller.navigationItem.leftBarButtonItem = self.splitViewController?.displayModeButtonItem()
controller.navigationItem.leftItemsSupplementBackButton = true
navigationController!.popToRootViewControllerAnimated(false)
navigationController!.pushViewController(controller, animated: true)
}
}
...
}
This gives us the behaviour that we want. The detail state is cached when we switch back and forth meaning that we don't lose our scroll position.
However, if you put the app in the background by hitting the home button then the cache seems to get flushed and scrolling positions for other detail views are lost!
3: Bonus - Surviving the Background
This step isn't completely necessary and very few apps implement it. Play around with switching detail views for different published apps on your own device and see which of them survive after being in the background.
NSCache is a black box that "incorporates various auto-removal policies" and doesn't necessarily remove items only during the classic didReceiveMemoryWarning()
memory pressure. As you can see, it may discard content when the app is in the background but we can make it less likely to trash our view controllers so quickly.
NSDiscardableContent is meant to be used to allow whatever implements it to track its own usage and discard its own content if necessary, which allows NSCache some finer grained control. But if we implement the bare minimum of required functionality and instead listen to didReceiveMemoryWarning()
, NSCache will keep our DetailViewControllers around even when our app goes into the background.
class DetailViewController: UIViewController, NSDiscardableContent {
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
func beginContentAccess() -> Bool {
return true
}
func endContentAccess() {}
func discardContentIfPossible() {}
func isContentDiscarded() -> Bool {
return false
}
...
}
Try it out and enjoy the nice little performance boost!
Do you have any questions or comments?
Feel free to post them and discuss in our dedicated channel on Gitter.