March 7, 2009

Trapping the UINavigationBar Back Button

I recently had a need to trap the back button tap on the navigation bar in order to do something before popping to the previous controller. However, I soon discovered that this was not possible. The standard back button is a "special" button that developers cannot override, at least not with the current public SDK.
The only thing you can change on the standard back button is the text. By default, this is the title of the controller. You can change it with code like this:
[self.navigationItem.backBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"New Title" style:UIBarButtonItemStyleBordered target:nil action:nil]; 
The important but subtle thing about a controller's backBarButtonItem property is that it is not the back button displayed when that controller is on top; it is for other controllers that are pushed on top of it. In other words, if you have a "root" controller whose title is something very long, you set its backBarButtonItem title to something shorter. Then when other controllers are pushed on the stack, they will use this back button item as their back button. This is unintuitive at first glance, but makes sense when you think about it: you can redefine the back button title once, and all other controllers that are pushed on top will use this back button.
Even though there are four parameters to the initializer, only the title is used for this "special" back button. The style specified seems to be ignored, as are the target and action parameters. Because of this, you cannot insert your own event handler for the back button, which is what I needed to do.
After some digging and research, I have a solution. It is not a pretty solution, but it's good enough.
The navigationItem property of a UIViewController has another property called leftBarButtonItem. This, like the backBarButtonItem, is a UIBarButtonItem object. If you set this to something other than nil, then this new button will replace the standard back button item. So now, I can get my custom back button event handler to work, because this one honors the style and the target/action parameters:
- (void) viewDidLoad
{
   // change the back button and add an event handler
   self.navigationItem.leftBarButtonItem =
   [[UIBarButtonItem alloc] initWithTitle:@"Pages"
                                    style:UIBarButtonItemStyleBordered
                                   target:self
                                   action:@selector(handleBack:)];
}

- (void) handleBack:(id)sender
{
    // do your custom handler code here

    // make sure you do this!
   // pop the controller
    [self.navigationController popViewControllerAnimated:YES];
}

If you still want this button to behave like a back button, it is important to put the popViewControllerAnimated: call in your handler. I should also say that this leftBarButtonItem should be set on the controller who will actually show the back button, unlike the backBarButtonItem. Now the reason I stated this wasn't a perfect solution is the following: The "special" back button looks different. Here is what the standard backBarButtonItem looks like:
Yet, the leftBarButtonItem looks like this:
I have been unable to force my custom back button to have the different shape. This is another indication that this back button object is treated specially that is not exposed in the SDK. Other than this little aesthetic discrepency, this approach met my needs. Hopefully it will meet yours as well.

11 comments:

  1. Great tip, thanks. Just what I was looking for.

    ReplyDelete
  2. navigationBar: shouldpopItem: is the message you are looking for it's on the navigationbar delegate.

    ReplyDelete
  3. I have the same problem. I can't change the button shape to the back button :'(

    ReplyDelete
  4. Using the Three20 Framework, you can add a TTButton styled as a back button:

    //Create the custom back button
    TTButton *backButtonView = [TTButton buttonWithStyle:@"toolbarBackButton:" title:@"Back"];
    [backButtonView addTarget:self action:@selector(goBack:) forControlEvents:UIControlEventTouchUpInside];
    [backButtonView sizeToFit];

    UIBarButtonItem *backButton = [[UIBarButtonItem alloc] initWithCustomView:backButtonView];
    self.navigationItem.leftBarButtonItem = backButton;
    [backButton release];

    ReplyDelete
  5. You can do something before the "popping" by subclassing the UINavigationContoller and overriding the popViewControllerAnimated-function. I explained everything here (including example code): http://www.hanspinckaers.com/custom-action-on-back-button-uinavigationcontroller

    ReplyDelete
  6. The following is my solution, as suggested by anonymous on 5/28 (please excuse the lack of formatting ...)

    ===========

    @interface CustomNavigationController : UINavigationController <UINavigationBarDelegate>
    {
    id target;
    SEL action;
    }

    @property(nonatomic) SEL action ;
    @property(nonatomic, assign) id target ;

    - (void) clearTargetAction ;
    - (BOOL)navigationBar:(UINavigationBar *)navigationBar shouldPopItem:(UINavigationItem *)item ;

    @end

    ========

    @implementation CustomNavigationController

    @synthesize target;
    @synthesize action;

    - (void) clearTargetAction
    {
    target = nil;
    action = NULL;
    }

    - (BOOL)navigationBar:(UINavigationBar *)navigationBar shouldPopItem:(UINavigationItem *)item
    {
    BOOL rv = TRUE;
    if (target && action)
    {
    rv = FALSE;
    objc_msgSend(target, action, self);
    } else
    {
    if ([super respondsToSelector:@selector(navigationBar:shouldPopItem:)])
    {
    rv = [(id <UINavigationBarDelegate>)super navigationBar:navigationBar shouldPopItem:item];
    }
    }

    return rv;
    }

    @end

    ==========

    // In my target, which is a viewController, I put the following:

    // Note important to do viewWillAppear, not viewDidLoad
    - (void)viewWillAppear:(BOOL)animated
    {
    // We want to handle the done button
    CustomNavigationController * navController = (CustomNavigationController *)self.navigationController;
    navController.target = self;
    navController.action = @selector(done);

    [super viewWillAppear:animated];

    }

    enum {
    TAG_DoneAlert = 1,
    TAG_CancelAlert
    };

    enum {
    Action_OK = 0,
    Action_Cancel
    };

    - (IBAction)done
    {
    UIActionSheet *actionSheet = [[UIActionSheet alloc]
    initWithTitle:@"Do you wish to keep your changes?"
    delegate:self
    cancelButtonTitle:@"Cancel"
    destructiveButtonTitle:@"OK"
    otherButtonTitles:nil];

    actionSheet.tag = TAG_DoneAlert;
    actionSheet.actionSheetStyle = UIActionSheetStyleBlackOpaque;
    [actionSheet showInView:self.view];
    [actionSheet release];
    }

    - (void)_returnBackToPreviousView
    {
    CustomNavigationController * navController = (CustomNavigationController *)self.navigationController;
    [navController clearTargetAction];
    [navController popViewControllerAnimated:YES];
    }

    - (void)_commitChanges
    {
    // ...
    }

    - (void)_handleDoneActionSheet:(NSInteger)buttonIndex
    {
    switch (buttonIndex) {
    case Action_OK:
    [self _commitChanges];
    [self _returnBackToPreviousView];
    break;
    default:
    break;
    }
    }

    ReplyDelete
  7. tank you so much!!!!

    it is very usefull for me!!!

    ReplyDelete
  8. I found a handy solution to this by simply setting the title of the controller before pushing another controller onto the stack, like this:

    ...
    self.navigationItem.title = @"Replacement Title";
    [self.navigationController pushViewController:newCtrl animated:YES];
    ...

    Then, make sure to set the previous title in viewWillAppear. This works because the default behavior of UINavigationController when constructing the back button during a push operation is to use the title from the previous controller.

    ReplyDelete
  9. Just what I was looking for! Thank you very much.

    ReplyDelete
  10. Thanks Henry but the simulator crashes on this line for me.

    navController.target = self;

    ReplyDelete
  11. A simple solution is to set the Controller's real title in the method viewWillAppear and the Controller's "back" title in viewWillDisappear. This will keep the styling of the navigationController and the shape of the left (back) button. Simple code:

    -(void) viewWillAppear: (BOOL) animated
    {
    [self setTitle:@"Current Controller"];
    }

    -(void) viewWillDisappear:(BOOL)animated
    {
    [self setTitle:@"Back"];
    }

    Regards,
    Mirrorps

    ReplyDelete