I have this UISplitViewController which both master and detail VCs are UINavigationController subclasses.
The two are supposed to work "in synchrony", ie, when one pushes a new VC, the second has to push one too. When one pops, the other has to pop too. One always triggers the same action to the other.
I'm already able to handle the pushing part of the problem, since the push functions are explicit in each class I use.
Popping, on the other hand, has been a big problem. The action is triggered when user presses the back button, and I don't know how to detect this event. One possible solution is detecting the event.
Another solution I thought of was to override UINavigationController's - popViewControllerAnimated:, making one class pop the other class, just like this:
// On DetailNav
- (UIViewController *)popViewControllerAnimated:(BOOL)animated {
// Code to make MasterNav pop
return [super popViewControllerAnimated:animated];
}
// On MasterNav
- (UIViewController *)popViewControllerAnimated:(BOOL)animated {
// Code to make DetailNav pop
return [super popViewControllerAnimated:animated];
}
I didn't bother adding the full code because this is enough to notice that this approach would cause an infinite-loop, eventually popping both NavControllers to their roots (and then possibly crashing).
What is the best way to achieve the desired behavior?
For iOS 5+, - (BOOL)isMovingFromParentViewController does the trick:
// Use this in the detail VC
- (void)viewWillDisappear:(BOOL)animated {
[super viewWillDisappear:animated];
if (self.isMovingFromParentViewController) {
// the view is being popped.
// so also pop the master VC
}
}
I found the solution on #Chrizzz's answer to another question.
Basically you need two subclasses of UINavigationController, one for master and one for detail.
In both subclasses, you must include UINavigationBarDelegate and set the delegate to self
. Then include the following method:
- (BOOL)navigationBar:(UINavigationBar *)navigationBar shouldPopItem:(UINavigationItem *)item {
[[[[self splitViewController] viewControllers][0 or 1] navigationController] performSelector:#selector(popViewControllerAnimated:) withObject:#YES afterDelay:0];
return YES;
}
In the master, you'll want to pop the detail VC, so put a 1 on the index.In the detail, you'll want to pop the master VC, so put a 0 on the index.
This solution allows you to run a routine before popping the view controller.
Update
I was getting some NavigationBar errors getting corrupted such as nested pop animation can result in corrupted navigation bar. So instead of directly calling popViewControllerAnimated: I called performSelector: with zero delay and nothing bad happens now when I pop my views.
Related
I have an iOS app that displays multiple screens and has different root controller for both iPhone and iPad. Here is a simplified example code to show what is going on.
if (iPad) {
self.sideMenuController = [LGSideMenuController sideMenuControllerWithRootViewController:viewControllerA
leftViewController:viewControllerB
rightViewController:nil];
} else {
self.sideMenuController = [LGSideMenuController sideMenuControllerWithRootViewController:viewControllerB
leftViewController:viewControllerA
rightViewController:nil];
}
[self.navigationController pushViewController:self.sideMenuController animated:NO];
I need to be able to tell when both controllers (ViewControllerA and ViewControllerB) is loaded.
I've implemented the following delegate
- (void)navigationController:(UINavigationController *)navigationController didShowViewController:(UIViewController *)viewController animated:(BOOL)animated {
if (self.viewControllerA.viewIfLoaded.window != nil && self.viewControllerB.viewIfLoaded.window != nil) {
// do stuff after both controllers have been loaded and it is current view.
}
}
The delegate solution works, but not sure if it is best practice. I check if viewControllerA and viewControllerB is not nil and current view controllers because I push other controllers in the navigation controller and don't want to do anything if that happens.
It seems fragile. You're making a lot of assumptions and not (as far as I can tell) asking the navigation controller the question you really want to know the answer to. That question would be (as far as I can tell):
Is the view controller that just got pushed one of VCA and VCB?
Is the other of that pair already the navigation controller's child?
Assuming that the root view controller always loads first, you could perform work in the viewDidLoad method of the non-root view controller, which you could determine by referencing the device type.
You can have some BaseClass that both viewControllers inherit from (ViewControllerA and ViewControllerB) and in that BaseClass, you can use viewDidLoad: method to run whatever code you want.
Assume we have three view controllers: 1, 2, and 3. Using the storyboard, it's pretty simple to unwind from view controller 3 to view controller 1 using an unwind segue. However, when unwinding, view controller 2 is briefly visible before view controller 1 is displayed. Is there any way to get from 3 to 1 without displaying 2 again?
View Controller 1:
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
NSLog(#"one did appear");
}
- (IBAction)goToTwo:(id)sender {
NSLog(#"#### segue to two");
[self performSegueWithIdentifier:#"TwoSegue" sender:self];
}
- (IBAction)unwindToOne:(UIStoryboardSegue *)sender {
}
View Controller 2:
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
NSLog(#"two did appear");
}
- (IBAction)goToThree:(id)sender {
NSLog(#"#### segue to three");
[self performSegueWithIdentifier:#"ThreeSegue" sender:self];
}
View Controller 3:
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
NSLog(#"three did appear");
}
- (IBAction)unwindToOne:(id)sender {
NSLog(#"#### unwind to one");
[self performSegueWithIdentifier:#"OneSegue" sender:self];
}
This produces the following log messages:
one did appear
segue to two
two did appear
segue to three
three did appear
unwind to one
two did appear
one did appear
I've tried using custom segues and disabling animation. Although removing animation makes view controller 2 appear for an even shorter period of time, it still appears. Is there any way to program this behavior?
Screenshot of storyboard:
This seems to be due to the way that unwind segues search for the nearest view controller which implements the unwind action you specified in the storyboard. From the documentation:
How an Unwind Segue Selects its Destination
When an unwind segue is triggered, it must locate the nearest view controller that implements the unwind action specified when the unwind segue was defined. This view controller becomes the destination of the unwind segue. If no suitable view controller is found, the unwind segue is aborted. The search order is as follows:
A viewControllerForUnwindSegueAction:fromViewController:withSender: message is sent to the parent of the source view controller.
A viewControllerForUnwindSegueAction:fromViewController:withSender: message is sent to the next parent view controller [...]
You can override canPerformUnwindSegueAction:fromViewController:withSender: if you have specific requirements for whether your view controller should handle an unwind action.
The view controllers in your example don't have a parent, so it seems that the fallback (which I can't see documentation for) is to instantiate each presentingViewController and call canPerformUnwindSegueAction on them in turn. Returning NO from this method in ViewControllerTwo doesn't prevent its instantiation and display, so that doesn't solve the issue.
I've tried your code embedded within a navigation controller, and it works fine. This is because in that case, UINavigationController is the parent of each of your view controllers, and it handles all the selection of a destination view controller. More documentation:
Container View Controllers
Container view controllers have two responsibilities in the unwind process, both discussed below. If you are using the container view controllers provided by the SDK, such as UINavigationController, these are handled automatically.
If you were to create a simple container view controller to act as the parent for your three view controllers, you could use its viewControllerForUnwindSegueAction method to check each of its child controllers for the existence of the unwind action, before calling canPerformUnwindSegueAction on that controller, and finally returning the first one of those which returns YES.
Selecting a Child View Controller to Handle An Unwind Action
As mentioned in How an Unwind Segue Selects its Destination, the source view controller of an unwind segue defers to its parent to locate a view controller that wants to handle the unwind action. Your container view controller should override the method shown in Listing 2 to search its child view controllers for a view controller that wants to handle the unwind action. If none of a container's child view controllers want to handle the unwind action, it should invoke the super's implementation and return the result.
A container view controller should search its children in a way that makes sense for that container. For example, UINavigationController searches backwards through its viewControllers array, starting from the view at the top of the navigation stack.
Listing 2 Override this method to return a view controller that wants to handle the unwind action.
- (UIViewController *)viewControllerForUnwindSegueAction:(SEL)action fromViewController:(UIViewController *)fromViewController withSender:(id)sender
Container view controller design has a whole article dedicated to it by Apple, which I won't duplicate here (more than enough of Apple's writing in this answer already!) but it looks like it will take some thought to get it right, as it depends on the exact role you want these to play in your application.
A quick workaround, to get your desired behaviour using unwind segues, could be to embed the view controllers in a UINavigationController, and then hide the navigation bar using
[self.navigationController setNavigationBarHidden:YES];
Josh's answer led me to a solution. Here's how to accomplish this:
Create a root UINavigationController, and assign it to a class that extends UINavigationController and overrides the segueForUnwindingToViewController:fromViewController:identifier method. This could be filtered by the identifier if desired.
CustomNavigationController:
- (UIStoryboardSegue *)segueForUnwindingToViewController:(UIViewController *)toViewController fromViewController:(UIViewController *)fromViewController identifier:(NSString *)identifier {
return [[CustomUnwindSegue alloc] initWithIdentifier:identifier source:fromViewController destination:toViewController];
}
Create a custom push segue, that behaves like a modal segue, but utilizes our navigation controller. Use this for all "push" segues.
CustomPushSegue:
-(void) perform{
[[self.sourceViewController navigationController] pushViewController:self.destinationViewController animated:NO];
}
Create a custom unwind segue, that uses the navigation controller to pop to the destination. This is called by our navigation controller in the segueForUnwindingToViewController:fromViewController:identifier method.
CustomUnwindSegue:
- (void)perform {
[[self.destinationViewController navigationController] popToViewController:self.destinationViewController animated:NO];
}
By utilizing a combination of these patterns, the second view controller never appears during the unwind process.
New log output:
one did appear
#### segue to two
two did appear
#### segue to three
three did appear
#### unwind to one
one did appear
I hope this helps someone else with the same issue.
Looks like custom segues are being used, so it's possible that's interfering somehow, although I've never seen it happn. I suggest you check out Apple's example project. It also has custom segues in it so it should serve as a good starting point for you.
Apple's Unwind Segue Example
I surprised you're seeing the behavior you're seeing, but one way to change it would be to use an explicit dismiss rather than unwind segues (this assumes the forward segues are modal).
Everything will look right if VC1 does this:
[self dismissViewControllerAnimated:YES completion:^{}];
Or if some other vc does this:
[vc1 dismissViewControllerAnimated:YES completion:^{}];
The only hitch is that you'll need a handle to vc1 if you want to dismiss from some other vc. You could use a delegate pattern to let vc1 know it should do the dismiss, but a simpler solution is to have vc2 or 3 post a notification when the unwind should happen.
VCs 2 or 3 can do this:
// whenever we want to dismiss
[[NSNotificationCenter defaultCenter] postNotificationName:#"TimeToDismiss" object:self];
And VC1 can do this:
[[NSNotificationCenter defaultCenter] addObserver:self
selector:#selector(doDismiss:)
name:#"TimeToDismiss"
object:nil];
- (void)doDismiss:(NSNotification *)notification {
[self dismissViewControllerAnimated:YES completion:^{}];
}
in my app, there is a chatting service. when the user wants to message someone new, he press on "new chat" and a Table View Controller is shown to select from the list of friends. i'm using unwind segues in order to go back to the previous view controller and share the data between the two view controllers (mainly the data will be the friend to have a chat with). the unwind segue works perfectly, however in my app, when the user goes back to the main VC, i fire a new segue in order to go to another VC directly where the user has the chat; and this isn't working. All segues are well connected and i tried NsLog in every corner and it's entering there and even the prepareForSegue: is being accessed. i tried putting an NsTimer, thought there might be some technical conflict, and it didn't work. i change the segue to the chat VC to a modal and now it's giving me this error:
Warning: Attempt to present on whose view is not in the window hierarchy!
i can access this VC in other ways and it's in the hierarchy. my questions are: what could be wrong? does unwind segues alter the windows hierarchies ?
PICTURE:
to present the problem more visually, the main VC i'm talking about is on the bottom left connected to a navigation view controller. when a user presses new chat, the two VC on the top right are presented (one to choose between friends or group, the other to show the friends/ groups). so when a friend is selected for say from the top right VC i should unwind segue to the main VC. as you can see from the main VC there is other segues. non of them can work if i do unwind segue and they do work if i operate normally.
The reason it is not working and is giving you that error is because things arent happening in the order you think they are.
When the unwind happens the view which is visible is not dismissed yet, therefore you are trying to perform a segue on a view which is in fact not in that hierarchy like the error says, take this for example, placing NSLog statements in the final view and then in the unwind method in your main view controller you can see the following:
2013-11-27 14:51:10.848 testUnwindProj[2216:70b] Unwind
2013-11-27 14:51:10.849 testUnwindProj[2216:70b] Will Appear
2013-11-27 14:51:11.361 testUnwindProj[2216:70b] View Did Disappear
Thus the unwind in the main view is getting called, the view will appear (your main view controller), and then your visible view is dismissed. This could be a simple fix:
#interface ViewController () {
BOOL _unwindExecuted;
}
#end
#implementation ViewController
- (void)viewWillAppear:(BOOL)animated
{
NSLog(#"Will Appear");
if (_unwindExecuted) {
_unwindExecuted = NO;
[self performSegueWithIdentifier:#"afterunwind" sender:self];
}
}
- (IBAction)unwind:(UIStoryboardSegue *)segue
{
NSLog(#"Unwind");
_unwindExecuted = YES;
}
Don't use timers or delays to try and anticipate when a view may exist.
Instead, use calls like: - (void)dismissViewControllerAnimated:(BOOL)flag completion:(void (^)(void))completion
The completion block will let you know when you've arrived back at the main VC. Alternatively, look at the various calls associated with segues so that you know precisely when you can perform operations on the new window.
If all else fails, there's always UIViewController viewDidAppear.
This is a common problem for the view controller that is handling the unwinding, because during unwind, that view controller is likely to not be in the window hierarchy.
To solve, I added a property segueIdentifierToUnwindTo to coordinate the unwinding.
This is similar to the answer by JAManfredi, but extending it to be able to segue to any view controllers.
#interface FirstViewController ()
// Use this to coordinate unwind
#property (nonatomic, strong) NSString *segueIdentifierToUnwindTo;
#end
#implementation FirstViewController
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
// Handle unwind
if (_segueIdentifierToUnwindTo) {
[self performSegueWithIdentifier:_segueIdentifierToUnwindTo sender:self];
self.segueIdentifierToUnwindTo = nil; // reset
return;
}
// Any other code..
}
// Example of an unwind segue "gotoLogin"
- (IBAction)gotoLogin:(UIStoryboardSegue*)sender {
// Don't perform segue directly here, because in unwind, this view is not in window hierarchy yet!
// [self performSegueWithIdentifier:#"Login" sender:self];
self.segueIdentifierToUnwindTo = #"Login";
}
#end
I also shared how I use unwind segues with a FirstViewController on my blog: http://samwize.com/2015/04/23/guide-to-using-unwind-segues/
okay solved this, the problem is that the view wasn't there yet and i had to delay the process by this:
[self performSelector:#selector(fireNewConv) withObject:nil afterDelay:1];
However, something interesting is that i tried delaying by NSTimer but it didn't work.
From what I can tell, it seems that pushing into UINavigationController back to back with animation created a dead lock on iOS7.
I initially struggled with a crash on iOS6 and came up with the solution of:
Create an array of view controllers to push onto the navigation controller after the initial push. So if there are three view controllers to be pushed (A, B and C) then this array would have B and C.
Implement UINavigationControllerDelegate's navigationController:didShowViewController:animated:
In the delegate method, simply check if there are more elements in the view controller array. If so, take the first element out of it and push that into the navigation controller.
So essentially if there are B and C view controllers to be pushed, navigationController:didShowViewController:animated: will be called three times; every time after the view controller gets pushed starting with A. Obviously, the last call wouldn't do anything since the array should be empty at that point.
Note that this approach worked fine on iOS6. However, this breaks in iOS7. It seems that when it tries to animate in the second push, the app freezes. After digging a little bit more I came up with the solution of pushing the second view controller in the following manner in the delegate implementation.
dispatch_async(dispatch_get_main_queue(), ^{
[navigationController pushViewController:viewController
animated:YES];
});
This seems to fix the problem, but I was wondering if anyone has experienced a similar thing and have a better explanation of what exactly is going on.
I'm not sure what problem you're having. I created a test app that has a navigation controller and four other controllers. The first controller has a button to push to the second controller, which is FirstPushedViewController. In that controller I have this code to push the next two controllers, and it worked fine in iOS 7:
#interface FirstPushedViewController ()
#property (strong,nonatomic) NSMutableArray *vcs;
#end
#implementation FirstPushedViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.navigationController.delegate = self;
UIViewController *orange = [self.storyboard instantiateViewControllerWithIdentifier:#"Orange"];
UIViewController *red = [self.storyboard instantiateViewControllerWithIdentifier:#"Red"];
self.vcs = [#[red,orange] mutableCopy];
}
-(void)navigationController:(UINavigationController *)navigationController didShowViewController:(UIViewController *)viewController animated:(BOOL)animated {
if (self.vcs.count > 0) {
[self.navigationController pushViewController:self.vcs.lastObject animated:YES];
[self.vcs removeLastObject];
}
}
I am creating an iPhone client for one of my apps that has an API. I am using the GTMOAuth2 library for authentication. The library takes care of opening a web view for me with the correct url. However I have to push the view controller myself. Let me show you some code to make things more clear:
- (void)signInWithCatapult
{
[self signOut];
GTMOAuth2ViewControllerTouch *viewController;
viewController = [[GTMOAuth2ViewControllerTouch alloc] initWithAuthentication:[_account catapultAuthenticaiton]
authorizationURL:[NSURL URLWithString:kCatapultAuthURL]
keychainItemName:kCatapultKeychainItemName
delegate:self
finishedSelector:#selector(viewController:finishedWithAuth:error:)];
[self.navigationController pushViewController:viewController animated:YES];
}
I have a "plus"/"add" button that I add to the view dynamically and that points to that method:
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAdd target:self action:#selector(signInWithCatapult)];
When I press the "add" button, what is supposed to happen is to open the web view with an animation, and then add an account to the accounts instance variable which populates the table view. This works fine if I add one account, but as soon as I try to add a second account, the screen goes black and two errors appear in the console:
nested pop animation can result in corrupted navigation bar
Finishing up a navigation transition in an unexpected state. Navigation Bar subview tree might get corrupted.
The only way that I found to avoid this problem was to disable animations when pushing the view controller.
What am I doing wrong please?
Typical situations
You push or pop controllers inside viewWillAppear: or similar methods.
You override viewWillAppear: (or similar methods) but you are not calling [super viewWillAppear:].
You are starting two animations at the same time, e.g. running an animated pop and then immediately running an animated push. The animations then collide. In this case, using [UINavigationController setViewControllers:animated:] must be used.
Have you tried the following for dismissing once you're in?
[self dismissViewControllerAnimated:YES completion:nil];
I got the nested pop animation can result in corrupted navigation bar message when I was trying to pop a view controller before it had appeared. Override viewDidAppear to set a flag in your UIViewController subclass indicating that the view has appeared (remember to call [super viewDidAppear] as well). Test that flag before you pop the controller. If the view hasn't appeared yet, you may want to set another flag indicating that you need to immediately pop the view controller, from within viewDidAppear, as soon as it has appeared. Like so:
#interface MyViewController : UIViewController {
bool didAppear, needToPop;
}
...and in the #implementation...
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
didAppear = YES;
if (needToPop)
[self.navigationController popViewControllerAnimated:YES];
}
- (void)myCrucialBackgroundTask {
// this task was presumably initiated when view was created or loaded....
...
if (myTaskFailed) { // o noes!
if (didAppear)
[self.navigationController popViewControllerAnimated:YES];
else
needToPop = YES;
}
}
The duplicated popViewControllerAnimated call is a bit ugly, but the only way I could get this to work in my currently-tired state.