On iOS 8, UISplitViewController appears to save and restore the state of its subviews, for example, whether the master view is hidden or not.
This is undesirable because my app should always show the master view in landscape and always hide it in portrait. If the user closes the app in landscape (landscape state is saved) and reopens it in portrait (landscape state is restored), then the UISplitViewController shows the master view in the wrong configuration.
I still need to supply a restoration identifier to the UISplitViewController so that is subview controllers have their own state saved and restored. So how does one prevent UISplitViewController from restoring its own state, or override this behavior?
I solved this by subclassing UISplitViewController and overriding - (void)decodeRestorableStateWithCoder:(NSCoder *)coder to do nothing. This way the split view controller does not have an opportunity to restore its views, but its child view controllers still participate in state restoration.
The first thing needed when implementing UI State Restoration is changing from using didFinishLaunchingWithOptions to willFinishLaunchingWithOptions. If you set the delegate now in the willFinish the collapse will be called as expected. The issue was likely the delegate was set too late and it had already collapsed without your special handling.
Another issue is that the restoration paths to the controllers are different when in landscape and portrait so could be getting in a weird state. Because of the change it can't automatically find the existing detail view controller and creates new instances and either both or one of them likely get thrown away by the split view delegate because of misconfiguration of the detail item. In the State Restoration docs under "Recreate Your View Controllers" on step 3 it says it looks for an already created view controller with the same path which sadly fails when restoring after an orientation/trait change because the path is different. So it falls back to step 4 and creates a brand new empty misconfigured detail controller and is the reason you see the wrong configuration of controllers.
To understand the restoration identifier paths, implement application:viewControllerWithRestorationIdentifierPath:coder: in the app delegate and output the path components you'll see in portrait the last to be restored looks like:
SplitViewController,
MasterNavigationController,
DetailNavigationController,
DetailViewController
...which corresponds the single hierarchy primary of the split view controller (note: DetailNavigationController is a hidden nested navigation controller in this configuration).
And in landscape the last two to be restored are:
SplitViewController,
MasterNavigationController,
MasterViewController
and
SplitViewController,
DetailNavigationController,
DetailViewController
...which corresponds to the primary and secondary controller hierarchies of the split view.
So now knowing the restoration path to DetailViewController can be different, you can understand that if you try to automatically restore a portrait path while the storyboard has initlaised in landscape it won't find that detail view controller and resort to creating a new one. So I think the solution is to give it a helping hand to find it regardless of how the restoration path was saved:
- (UIViewController *)application:(UIApplication *)application viewControllerWithRestorationIdentifierPath:(NSArray *)identifierComponents coder:(NSCoder *)coder{
if([identifierComponents.lastObject isEqualToString:#"DetailViewController"]){
UISplitViewController *splitViewController = (UISplitViewController *)self.window.rootViewController;
UINavigationController *secondaryNavigationController = splitViewController.viewControllers.lastObject;;
DetailViewController *detail = (DetailViewController *)secondaryNavigationController.viewControllers.firstObject;
return detail;
}
return nil;
}
Now the restoration will correctly use the existing detail controller which is configured correctly and it won't be thrown away by the split view delegate which was resulting in you being left with the master.
Another way this issue can manifest is seeing two detail controllers pushed onto the navigation stack after restoration, which is what happens if you force the split view delegate to not throw away the initial detail controller, and when the restoration creates another one you end up with two pushed on!
Related
I have a strange problem with state restoration for a Universal app with Split View Controller.
The strange thing that I am doing things in a very standard way using a Storyboard and segues and with a restoration identifier for alle relevant view controllers. There is not really any code, as the logic is in the Storyboard and a minimal XCode project shows this.
The problem is with a settings screen that is shown modally as a form sheet presented from the split view controller. My view controller hierarchy ends up correct, but the transition doesn't really make sense. For some reason state restoration animates the modal controller into place.
Since the screen starts out with a screenshot from the last time the app was running, with the settings controller already present, the animation is just visual noise.
I have tried to disable animation on the segue which is respected when entering the settings interactively, but when state restoration does the same thing, the animation is there.
What is the standard way to avoid this?
Calling self.window?.makeKeyAndVisible() in application(_:willFinishLaunchingWithOptions:) solved the issue for me.
More info in the docs:
Important
If your app relies on the state restoration machinery to restore its
view controllers, always show your app’s window from this method. Do
not show the window in your app’s
application:didFinishLaunchingWithOptions: method. Calling the
window’s makeKeyAndVisible method does not make the window visible
right away anyway. UIKit waits until your app’s
application:didFinishLaunchingWithOptions: method finishes before
making the window visible on the screen.
My storyboard has: NavigationController -> UIViewController 1 -> UIViewController 2 -> UIViewController 3 -> UITabBarController -> four UIViewControllers (a,b,c,d)
I'm adding restoration support to the app. I've added it to UIViewController #1 and #3 and am now adding it to UIViewController 'c' under the UITabBarController.
When I kill my app using Xcode when it's displaying UIViewController 'c' and then start the app again the viewcontroller's methods are called correctly and the content of its UITextFields are restored correctly.
But in placing breakpoints in 'c' view controller I can see that UIViewController 3 is temporarily displayed. It's viewDidLoad and viewWillAppear: methods are called.
What would typically be the cause of UIViewController #3 being displayed?
I've pressed on implementing restoration for my UIViewControllers and there doesn't appear to be any negative effect from an end-users point of view - without the breakpoints the display doesn't get a chance to update to show the intermediate view.
That's what view controller restoration is. The app launches from scratch; to reach the view controller that was being displayed at termination, the app walks the view controller creation sequence down the chain from the root view controller to the view controller that is to be displayed to the user. It couldn't possibly happen any other way. (After all, if VC2's Back button is to lead to VC1, then VC2 must be above VC1 in the nav controller stack.) And you wouldn't want it not to happen, because you might have important work to do as the sequence is reconstructed. By setting breakpoints in the middle of the restoration process, you have revealed "the man behind the curtain". But the user doesn't see that process (it is covered by the launch image), so there's no problem.
In particular it is very important that viewDidLoad is called in correct sequence right down the chain of creation, and that this happens for all view controllers before they are sent decodeRestorableStateWithCoder:, also in order down the chain.
(However, you should not count on anything having to do with viewWillAppear: and its cousins. These may or may not be called and you can't be sure just when. I regard this inconsistency / unpredictability with regard to the timing of viewWillAppear: as a bug, but Apple, unfortunately, does not.)
My app has a simple organization, which I've configured in an Interface Builder storyboard (not in code). There is a Navigation View Controller, which has its Root View Controller set to my Main View Controller. My Main View contains a table, where cells segue to a Detail View Controller.
When I suspend the application while looking at the Detail View and then resume it, I'm returned to the Main View, rather than the Detail view. Why might this be?
Details:
I have set Restoration IDs in Interface Builder for the Navigation View Controller, the Main View Controller and the Detail View Controller. I've also tried adding a Restoration ID to the Table View and making the Main View Controller implement UIDataSourceModelAssociation.
My app is returning YES from shouldRestoreApplicationState and both the Main View and the Detail View have encode/decodeRestorableStateWithCoder methods.
I'm testing suspend/resume using the simulator: I run the app, navigate to the Detail View, hit the home button, and then click the stop button in XCode. To resume, I'm running the app again from XCode.
I see the following calls on suspend:
AppDelegate shouldSaveApplicationState
MainViewController encodeRestorableStateWithCoder
DetailViewController encodeRestorableStateWithCoder
And on resume:
AppDelegate shouldRestoreApplicationState
AppDelegate viewControllerWithRestorationIdentifierPath Navigation
AppDelegate viewControllerWithRestorationIdentifierPath Navigation/MainView
MainViewController viewDidLoad
AppDelegate viewControllerWithRestorationIdentifierPath Navigation/DetailView
MainViewController decodeRestorableStateWithCoder
In addition to the wrong view being restored, there's something else odd: Why is the Restoration Identifier Path for the Detail View "Navigation/DetailView" and not "Navigation/MainView/DetailView"? There is no direct relationship between the Navigation View Controller and the Detail View Controller. Their only connection in Interface Builder is via the segue from the Main View.
Have I misconfigured something?
I have tried assigning a Restoration Class to the Detail View. When that restoration code is invoked, it fails because the UIStateRestorationViewControllerStoryboardKey is not set in the coder.
Here's a toy version of my project which replicates the problem: https://github.com/WanderingStar/RestorationTest
I'm trying this with XCode Version 5.0 (5A1413) and iOS Simulator Version 7.0 (463.9.4), in case those are relevant.
The answer turned out to be simple: I was not calling
[super encodeRestorableStateWithCoder:coder];
in the encodeRestorableStateWithCoder:coder method in my View Controllers (and doing the same in decode...) which is what sets the storyboard in the coder.
This tutorial helped me step through each step of the process, and find out where I'd gone wrong:
http://useyourloaf.com/blog/2013/05/21/state-preservation-and-restoration.html
Also, it turns out that "Navigation/DetailView" is what's expected. The Navigation View Controller restores all of the views in its stack and then puts them back into the stack, rather than each view restoring the later views in the stack.
In the iOS App Programming Guide, section "State Preservation and Restoration" there is a convenient checklist for what you have to do to make restoration work.
After looking at your code it seems that you forgot step 3, Assign Restoration Classes. Your classes do not have these properties, and you did not implement viewControllerWithRestorationIdentifierPath in the app delegate.
Assign restoration classes to the appropriate view controllers. (If you do not do this, your app delegate is asked to provide the corresponding view controller at restore time.) See “Restoring Your View Controllers at Launch Time.”
I took a look at your sample and the applicationWillFinishLaunching is missing [self.window makeKeyAndVisible] which is a requirement for state restoration. This will make the split controller immediately collapse and then it will be restored correctly.
There is an issue that if it was preserved in landscape, i.e. separated split view , and then launched in portrait then the path will not be correct. In this case at launch it will first collapse to match the current screen, then it begin restore and first separate, then after restore has finished it will collapse again to match the current screen. During this time you need to implement viewControllerWithRestorationIdentifierPath and use the last string in the path to identify the controller and return it after having captured it from what the storyboard created initially in will finish launching. Then you can clear those properties in didFinish.
In this current SplitViewController constellation the MasterVC performs an operation/calculation in viewWillAppear. The result of that operation is needed in the DetailVC to load its view properly (the DetailVC has a table view).
I don't know how the loading/appearing sequence in a SplitViewController is (and how reliable it might be if known), but is it possible to
Perform the operation/calculation in the MasterVC on viewWillAppear
Pass the information to the DetailVC (either using the properties of DetailVC or with a protocol)
DetailVC will be loaded when the required information is available (e.g. viewWillLoad/viewWillAppear of DetailVC)?
Both the master and detail viewDidLoad methods are called at start up with a split view controller. So, you can't control when the detail controller gets loaded based on something happening in the master view controller. The detail view controller's viewWillAppear is called before the master's viewWillAppear since you only see the detail view at start up (in portrait orientation). So, you probably need to have a placeholder view in the detail view controller, if you want something to appear there before the master controller does its calculation.
This is only true if the iPad is in portrait orientation when the app starts. If you have it turned to the landscape orientation when you start the app, then you get the viewDidLoad and viewWillAppear of the master before either of those methods gets called in detail.
I am using a UISplitViewController inside a UITabBarController with a plain UIViewController in the master pane of the split view and a UINavigationController in the detail pane, which itself contains a vanilla UIViewController.
I am aware that Apple advise to use split views at the root level only, however I have seen other applications (eg, Amazon- 'Wish List' tab) that use split views in tabs so I'm sure it's possible.
My problem is that the delegate methods of the split view, ie. those in UISplitViewControllerDelegate do not get called, which prevents me from creating my pop-over menu when switching into Portrait mode.
The methods in question are the following -
// Called when a button should be added to a toolbar for a hidden view controller
- (void)splitViewController: (UISplitViewController*)svc willHideViewController:(UIViewController *)aViewController withBarButtonItem:(UIBarButtonItem*)barButtonItem forPopoverController: (UIPopoverController*)pc;
// Called when the view is shown again in the split view, invalidating the button and popover controller
- (void)splitViewController: (UISplitViewController*)svc willShowViewController:(UIViewController *)aViewController invalidatingBarButtonItem:(UIBarButtonItem *)barButtonItem;
// Called when the view controller is shown in a popover so the delegate can take action like hiding other popovers.
- (void)splitViewController: (UISplitViewController*)svc popoverController: (UIPopoverController*)pc willPresentViewController:(UIViewController *)aViewController;
The UISplitViewController does receive the rotation notifications.
I can get the willShowViewController method to be called if I force the status bar orientation to landscape right (or left) at the beginning of the app launch, using
[[UIApplication sharedApplication] setStatusBarOrientation:UIInterfaceOrientationLandscapeRight];
However, the willHideViewController doesn't get called. And I don't want to force the app to start in landscape. If I do the same thing but force it to portrait, I don't receive the callbacks.
I don't understand why the split view controller is not calling it's delegate methods when it is otherwise behaving correctly. These methods should be called from its method-
- (void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration
internally, and when I breakpoint inside this, I can check that the delegate is set and that it is still alive.
Been stuck on this all day! Everything else is working great and I'm very pleased that the splitview / tabbar / navbar combination is working well. I just need these notifications.
Should I perhaps just call them manually when I rotate? Seems very wrong when the `UISplitViewController' should be doing this.
Solved, it has to be at either root level or a direct subview of a tabBar which also must be at root level. Annoying!
First, try to see if you are setting the correct delegates.
e.g., lets say you created three controllers,
UISplitViewController* splitView;
UIViewController* masterView;
UIViewController* detailView;
You implemented the delegate protocol at the detail view, so that when orientation changes, detail view should be able to put a button in the toolbar.
Now in order for splitView to call this function from delegate, you need to set the delegate itself.
So somewhere, if you are missing the following call,
splitView.delegate = detailView;
detailView's will never get notified of the orientation changes etc. At least this is where I was stuck.
I like the following method of sending a message from the master UIViewController to the detail UIViewController. Somewhere inside the master's implementation:
id detailViewController = [[self.splitViewController viewControllers] lastObject];
[detailViewController setSomeProperty:…];
This is from Paul Hegarty's Fall 2011 Stanford iTunesU iPad and iPhone Application Development course.