iosdev

State restoration in iOS 6 without storyboards

Back in iOS 6, Apple added a set of APIs with specific aim to greatly increase user experience during app switching. That API is Application State Preservation and Restoration - you can learn about it using the official docs or WWDC 2012 Session 208 or using a series of great posts by Keith Harrison.

What’s common about these and few other resources on the web is that they lean on Storyboards and if you happen to not use them - like me in this particular case - then it’s not entirely clear where you do start. This API was confusing to me at first, but once I implemented it, it was really obvious how good this API is for people, in everyday usage. It’s worth the time.

Also, an important note: with iOS 7 and its outlined principles and human interface guidelines provided by Apple, this API became a whole lot more important. The transitions between the apps are much more natural and re-assuring if you have properly implemented this API.

In this post I’ll show you how I’ve implemented it in my running apps. They do not use storyboards and their basic structure is tab-bar controller with each tab containing a navigation controller with rich navigation stack inside.

App delegate code

You start from App delegate, where you most likely need to re-organize your code first.

House keeping first

Previously, all the code that setup global stuff needed for my app was in this familiar method:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {

	//	setup TestFlight API key
	//	setup Google Maps API key

	self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];

	//	customize app skin (UIAppearance stuff)
	//	setup Core Data stack
	//	setup global data manager singleton, assign CoreDataStack reference to it
	//	check and perform initial data load, if not done before

	//	create instances of all navigation controllers
	//	create instance of tab bar controller and assign child NCs to it

	[self.window makeKeyAndVisible];

	return YES;
}

You know, the usual stuff :).

Starting from iOS 6, there’s another method, very similar to that one: application:willFinishLaunchingWithOptions:. This method is crucial for the state restoration to work properly. If you have been through the docs/tutorials, you have noticed that the order of business now is:

  1. application:willFinishLaunchingWithOptions:
  2. process application state restore
  3. application:didFinishLaunchingWithOptions:

So, to prepare your code, you now need to do this:

- (BOOL)application:(UIApplication *)application willFinishLaunchingWithOptions:(NSDictionary *)launchOptions {

	//	setup TestFlight API key
	//	setup Google Maps API key

	self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];

	//	customize app skin (UIAppearance stuff)
	//	setup Core Data stack
	//	setup global data manager singleton, assign CoreDataStack reference to it
	//	check and perform initial data load, if not done before

	//	create instances of all navigation controllers
	//	create instance of tab bar controller and assign child NCs to it

	return YES;
}

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {

	[self.window makeKeyAndVisible];
	return YES;
}

This is easy. Move entire data and hierarchy setup to will method and leave did method more or less empty.

Opt-in for state preserve/restore

Now, add these two methods to notify iOS that your app will preserve/restore its state. I copied the code in the first method entirely from Keith’s post:

#define BUNDLEMINVERSION 3

- (BOOL)application:(UIApplication *)application shouldRestoreApplicationState:(NSCoder *)coder {

    // Retrieve the Bundle Version Key so we can check if the restoration data is from an older
    // version of the App that would not make sense to restore. This might be the case after we
    // have made significant changes to the view hierarchy.
    
    NSString *restorationBundleVersion = [coder decodeObjectForKey:UIApplicationStateRestorationBundleVersionKey];
    if ([restorationBundleVersion integerValue] < BUNDLEMINVERSION) {
        NSLog(@"Ignoring restoration data for bundle version: %@",restorationBundleVersion);
        return NO;
    }
    
    // Retrieve the User Interface Idiom (iPhone or iPad) for the device that created the restoration Data.
    // This allows us to ignore the restoration data when the user interface idiom that created the data
    // does not match the current device user interface idiom.
    
    UIDevice *currentDevice = [UIDevice currentDevice];
    UIUserInterfaceIdiom restorationInterfaceIdiom = [[coder decodeObjectForKey:UIApplicationStateRestorationUserInterfaceIdiomKey] integerValue];
    UIUserInterfaceIdiom currentInterfaceIdiom = currentDevice.userInterfaceIdiom;
    if (restorationInterfaceIdiom != currentInterfaceIdiom) {
        NSLog(@"Ignoring restoration data for interface idiom: %d",restorationInterfaceIdiom);
        return NO;
    }
    
	return YES;
}

 - (BOOL)application:(UIApplication *)application shouldSaveApplicationState:(NSCoder *)coder {

	return YES;
}

Assign restorationIdentifiers

Ok, here comes the part that is done in the IB if you use storyboards. For each controller which state you want to preserve, you need to populate its restorationIdentifier property. If you’re not using storyboards, this is how it’s done:

	// Setup tabs
	AboutViewController *avc = [[AboutViewController alloc] initWithStyle:UITableViewStylePlain];
	UINavigationController *anc = [[UINavigationController alloc] initWithRootViewController:avc];
	anc.restorationIdentifier = @"AboutNC";
	
	SettingsViewController *svc = [[SettingsViewController alloc] initWithStyle:UITableViewStylePlain];
	UINavigationController *snc = [[UINavigationController alloc] initWithRootViewController:svc];
	snc.restorationIdentifier = @"SettingsNC";
	
	MusicViewController *mvc = [[MusicViewController alloc] initWithNibName:nil bundle:nil];
	UINavigationController *mnc = [[UINavigationController alloc] initWithRootViewController:mvc];
	mnc.restorationIdentifier = @"MusicNC";

	RTOverviewLayout *ol = [[RTOverviewLayout alloc] init];
	OverviewViewController *ovc = [[OverviewViewController alloc] initWithCollectionViewLayout:ol];
	ovc.managedObjectContext = self.coreDataStack.managedObjectContext;
	UINavigationController *onc = [[UINavigationController alloc] initWithRootViewController:ovc];
	onc.restorationIdentifier = @"OverviewNC";
	
	ProgressViewController *pvc = [[ProgressViewController alloc] initWithStyle:UITableViewStylePlain];
	pvc.managedObjectContext = self.coreDataStack.managedObjectContext;
	UINavigationController *pnc = [[UINavigationController alloc] initWithRootViewController:pvc];
	pnc.restorationIdentifier = @"ProgressNC";
	
	self.tabBarController = [[UITabBarController alloc] init];
	self.tabBarController.restorationIdentifier = @"MainTBC";
	self.tabBarController.viewControllers = [NSArray arrayWithObjects:onc, pnc, mnc, anc, snc, nil];

With just the code above, you have already achieved something: you have preserved the selected tab. If I tap on 2nd tab (Progress…) and exit the app, once I get back to it that tab will be automatically selected. This is UIKit doing the work for you - in case of UITabBarController, it saves the tab selection state. UIKit does a whole lot more for you - again, go through the docs/videos to learn what.

Note that I’m populating only those controllers that are actually used in AppDelegate and are not referred to anywhere else. Meaning, I’m adding restorationIdentifier to UITabBarController and to each UINavigationController but not to the top view controllers inside UINC.

I do that in their respective init methods, as you will see now.

View controller stuff

Ok, now one step at the time. First, let’s add restoration identifiers to each view controller and to their views. That way, we are notifying UIKit to do its work.

Progress, About and Settings VCs are UITableViewController subclasses, so this code is enough for them:

- (id)initWithStyle:(UITableViewStyle)style {
	self = [super initWithStyle:style];
	if (self) {
		self.restorationIdentifier = @"ProgressVC";
		
	}
	return self;
}

- (void)viewDidLoad {
    [super viewDidLoad];
	
	self.tableView.restorationIdentifier = self.restorationIdentifier;

}

Reading the docs, I was lost for a bit which of the various state preservation paths / options I should use for each of the view controllers. Do I need restoration class for each one or not? After some trial and error, these are the rules:

By “pre-built”, I mean that it’s either loaded from storyboard or it’s always instantiated from code. In my case, all top level VCs are already created in application:willFinishLaunchingWithOptions: so all I needed was the code above.

Ok, what about VCs loaded deeper in navigation stack?

When is restoration class needed

In About VC, tapping on some table cells loads a Help view controller, which is basically a container for web view into which I load an HTML file.

In order for this VC to be restored, this is the minimum required code. First, you need to opt-in with a new protocol in iOS 6, called UIViewControllerRestoration:

@interface HelpViewController : UIViewController < UIViewControllerRestoration >

@end

This protocol has only one class method:

+ (UIViewController *)viewControllerWithRestorationIdentifierPath:(NSArray *)identifierComponents coder:(NSCoder *)coder {
	
	HelpViewController *vc = [[self alloc] initWithNibName:nil bundle:nil];
//	NOTE:this is the place to restore VC state-keeping properties 
	
	return vc;
}

However, if you leave it like this, restoration would not work. You need to assign both restorationIdentifier and restorationClass:

- (id)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil {
	
	self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil];
	if (self) {
		self.restorationIdentifier = @"HelpVC";
		self.restorationClass = [self class];
	}
	
	return self;
}

You also need to set restorationIdentifier to the view, either in viewDidLoad or in IB (since this VC has its .xib file, I did it there).

Now, when restoration starts, viewControllerWithRestorationIdentifierPath:coder: will be called and help page would show up.

Or would it..?

Encoding VC properties required for state

Did you notice this comment:

+ (UIViewController *)viewControllerWithRestorationIdentifierPath:(NSArray *)identifierComponents coder:(NSCoder *)coder {
	
//	NOTE:this is the place to restore VC state-keeping properties
...
}

In order for the page to load, Help VC needs to know the file path. In About VC, on tapping the table view cell, I do this:

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
	[tableView deselectRowAtIndexPath:indexPath animated:YES];
	switch (indexPath.section) {
		
		case kFlipTipsSection: {
			if (self.helpController == nil) {
				HelpViewController *hvc = [[HelpViewController alloc] initWithNibName:nil bundle:nil];
				hvc.hidesBottomBarWhenPushed = YES;
				self.helpController = hvc;
			}
			
			switch (indexPath.row) {
				case kFlipTipsSectionHealthRow:
					self.helpController.pageFileName = @"health";
					break;
				...
			}
			[self.navigationController pushViewController:self.helpController animated:YES];
			break;
		}
	}	
}

As you can see, there are two properties that define the state:

So, we need to do two things.

  1. Save these two
  2. Restore them and assign to re-created VC

Here they are:

#define RTRunHelpSRKeyPageFileName	 @"RTRunHelpSRKeyPageFileName"

- (void)encodeRestorableStateWithCoder:(NSCoder *)coder {
	
	[coder encodeObject:self.pageFileName forKey:RTRunHelpSRKeyPageFileName];
	
	[super encodeRestorableStateWithCoder:coder];
}

+ (UIViewController *)viewControllerWithRestorationIdentifierPath:(NSArray *)identifierComponents coder:(NSCoder *)coder {
	
	HelpViewController *vc = [[self alloc] initWithNibName:nil bundle:nil];
	vc.pageFileName = [coder decodeObjectForKey:RTRunHelpSRKeyPageFileName];
	vc.hidesBottomBarWhenPushed = YES;
	
	return vc;
}

This is a fairly simple VC, so it’s straight-forward what to do.

Proper restore of the view state

View state can mean many things. The most obvious thing people would notice is the scrolling offset - if you have a large table view and if you don’t restore it exactly where user left it scrolled, state restoration magic will be lost.

The simple solution would be to encode scrolling offset and restore it. But that’s a fragile attempt and very prone to errors, especially in data-driven views like table/collection views. So what Apple did is they provided an API to specifically deal with this case. Instead of scrolling offset, you remember what data items were visible and restore that. Even if you happen to change data source between the app sessions, if the originally visible item is still in the store, table view will be restored with that item visible at the same visual position.

First, you need to conform to new data protocol:

@interface AboutViewController () < UIDataSourceModelAssociation >

And then you implement its two methods:

- (NSString *)modelIdentifierForElementAtIndexPath:(NSIndexPath *)idx inView:(UIView *)view {
	
	if (!idx || !view) return nil;
	
	return [[self.dataRows objectAtIndex:idx.section] objectAtIndex:idx.row];
}

- (NSIndexPath *)indexPathForElementWithModelIdentifier:(NSString *)identifier inView:(UIView *)view {
	
	if (!identifier || !view) return nil;
	
	__block NSIndexPath *indexPath = nil;
	[self.dataRows enumerateObjectsUsingBlock:^(NSArray *obj, NSUInteger idx, BOOL *stop) {
		NSInteger row = [obj indexOfObject:identifier];
		if (row == NSNotFound) return;

		indexPath = [NSIndexPath indexPathForRow:row inSection:idx];
		*stop = YES;
	}];
	
	// iOS 6 bug workaround:
	// Force a reload when table view is embedded in nav controller or scroll position is not restored.
	[self.tableView reloadData];
	
	return indexPath;
}

First method is the encoding part. When session preserving starts, UIKit will call it for visible cells. The string you return should somehow be connected to actual data source. In this simple table view, my data source is an array of strings - text labels shown in cells. They are all unique, so I can use them for encoding.

Second method will take that encoded string for each cell and return NSIndexPath that corresponds to that string. So you see, since we are not encoding old index paths we will be able to restore to the same cells regardless of what their new index paths are.

General view stuff

Ok, so this deals with scrolling. What about other stuff that influence the view state?

For example, in Progress VC, I have a button, next to each section title, to reveal summary for each run. This is the kind of stuff that are result of user action and thus need to be preserved and restored. There are two new UIView APIs just for this; we already used one before.

#define RTRunDetailsSRKeyNotes 	@"RTRunDetailsSRKeyNotes"

- (void)encodeRestorableStateWithCoder:(NSCoder *)coder {
	
	[coder encodeObject:self.sectionsWithShownNotes forKey:RTRunDetailsSRKeyNotes];

	[super encodeRestorableStateWithCoder:coder];
}

- (void)decodeRestorableStateWithCoder:(NSCoder *)coder {

	NSSet *notes = [coder decodeObjectForKey:RTRunDetailsSRKeyNotes];
	if ([notes count] > 0) {
		[self.sectionsWithShownNotes addObjectsFromArray:[notes allObjects]];
		[self.tableView reloadData];
	}

	[super decodeRestorableStateWithCoder:coder];
}

I use NSMutableSet to keep track of indexes of sections where summaries are shown. This set is encoded during preservation run and then restored when table view is loaded. Easy peasy.

Believe it or not - that’s it. All you needed to do. Make sure that you never even attempt to encode/decode data models. Think about what’s required for both the controller and its view to appear and encode that. Re-use as much of your existing code and view lifecycle as possible.

In upcoming post, I will demonstrate more advanced stuff - how to deal with VC where data model is Core Data based (like mentioned Progress VC) and what to do with deeper VC that are also based on Core Data. Like for example RunDetails VC which is loaded when you tap on cell representing completed run in Progress table view and it’s given a NSManagedObject as data source.