Implementing GUI persistence in an iPhone App

by Martin Westin in ,


With iOS 4, Apple pushes everyone to build our apps so that we preserve the state of the application when it terminates. This is because to the normal user there is no difference between an app being "pushed" to the background and an app being terminated. Apple want the users to feel like our apps never terminate. That they just leave them in the background a while. I'll explain how I implemented this behaviour using NSUserDefaults in my app, Extraction. It may not be the most advanced technique or the best in any way. I just know it works for me.

I will use the main view in Extraction to exemplify this. It requires that I "remember" the following things when my app terminates, receives a memory warning or is suspended in the background.

  • The selected item in the basket selector (at the top)
  • The state of the main button
  • The time of the timer if it is running
  • Finally, the scroll position of the Table View listing the stored extractions

To handle the saving the restoration of the state of these elements I created two methods in my MainViewController, called saveGuiState and restoreGuiState of all things.

The save method stores each of these values in NSUserDefaults. This is really made to be a preference storage but I hope it is OK with Apple to use it for this as well :)

- (void)saveGuiState {
	// basket selector state
	[[NSUserDefaults standardUserDefaults] setInteger:[basketSelector selectedSegmentIndex] forKey:@"basketSelectorIndex"];

	// button state based on internal "runlevel" integer
	[[NSUserDefaults standardUserDefaults] setInteger:running forKey:@"running"];

	// timer interval
	[[NSUserDefaults standardUserDefaults] setDouble:startInterval forKey:@"startInterval"];

	// Table scrolling
	CGPoint contentOffset = [viewTable contentOffset];
	[[NSUserDefaults standardUserDefaults] setFloat:contentOffset.y forKey:@"contentOffsetY"];
}

It does nothing very complicated. It simply takes each value I need to restore the GUi and stores it to the Defaults database.

The most interesting detail for me was discovering the contentOffset property for UIScrollView (and also the subclass UITableView). It allows me to "scroll" the table to the exact pixel value it was scrolled to before. Very handy.

The restore method does the reverse. It sets each parameter in the GUI to the value in NSUserDefaults (if it exists).

- (void)restoreGuiState {

	// basket selector
	if ( NULL != [[NSUserDefaults standardUserDefaults] objectForKey:@"basketSelectorIndex"] ) {
		[basketSelector setSelectedSegmentIndex:[[NSUserDefaults standardUserDefaults] integerForKey:@"basketSelectorIndex"]];
	}

	// timer interval
	if ( NULL != [[NSUserDefaults standardUserDefaults] objectForKey:@"startInterval"] ) {
		startInterval = [[NSUserDefaults standardUserDefaults] doubleForKey:@"startInterval"];
		elapsedTime = [NSDate timeIntervalSinceReferenceDate] - startInterval;

		// restore timer if we have not been gone too long
		if ( elapsedTime < 90 ) {

			// running state
			if ( NULL != [[NSUserDefaults standardUserDefaults] objectForKey:@"running"] ) {
				running = [[NSUserDefaults standardUserDefaults] integerForKey:@"running"];

				// a little method for setting the button label for each run level
				[self stepButtonTitleForRunningState:running];

				if ( running && ![myTimer isValid] ) {
					// restart an invalidated timer
					myTimer = [NSTimer scheduledTimerWithTimeInterval: 0.05 target: self selector: @selector(updateTime) userInfo: nil repeats: YES];
					// set the current timer object to the last one created
					current = [results objectAtIndex:[results count] - 1];
				}
			}

		}

	}

	// Table scrolling
	[viewTable reloadData];
	if ( NULL != [[NSUserDefaults standardUserDefaults] objectForKey:@"contentOffsetY"] ) {
		CGPoint contentOffset = CGPointMake(0.0,[[NSUserDefaults standardUserDefaults] floatForKey:@"contentOffsetY"]);
		[viewTable setContentOffset:contentOffset animated:NO];
	} else {
		NSIndexPath *scrollIndexPath = [NSIndexPath indexPathForRow:([results count] - 1) inSection:0];
		[viewTable scrollToRowAtIndexPath:scrollIndexPath atScrollPosition:UITableViewScrollPositionBottom animated:YES];
	}
}

Something to note here is that the if clauses check against the objectForKey value and not the integer or float. This is so it will return true even for 0 values that are set. For example, the default value for the selector is a double (index=1) if I checked against the integer I would never restore to a single basket setting.

Also the nested ifs are there so that I only restore timer-related things if it is appropriate.

These two methods are run then the view unloads and loads, respectively. I call the restore method in viewWillAppear and I set a bool so that I don't do it repeatedly unless the view has been unloaded since it was last seen. I think it has to do with the UITableView needing to populate itself and viewDidLoad was too early.

The save method is called from two places. viewDidUnload, obviously, and my "applicationWillTerminate" (which received the system notifications for both termination and entering background in iOS4). This is because viewDidUload does not fire when terminating the application and you are supposed to save state when entering background and about to be suspended.

One very important detail to note here is that NSUserDefaults syncs up with the OS periodically.... not immediately. Anything that is saved just before termination is very very very likely to be lost. It is a very simple fix though:

[self saveGuiState];
[[NSUserDefaults standardUserDefaults] synchronize];

That is it. That is all I do to make my app behave like it never quits. It even gets some multitasking feeling on non-multitasking devices. You can make a very quick phone call on your iPhone 3G or OG (The Original Generation iPhone) and return to Extraction in time to stop the timer. Pretty cool.