29

Suppose I have a container controller that accepts an array of UIViewControllers and lays them out so the user can swipe left and right to transition between them. This container controller is wrapped inside a navigation controller and is made the root view controller of the application's main window.

Each child controller makes a request to the API and loads a list of items that are displayed in a table view. Based on the items that are displayed a button may be added to the navigation bar that allows the user to act on all the items in the table view.

Because UINavigationController only uses the UINavigationItems of its child view controllers, the container controller needs to update its UINavigationItem to be in sync with the UINavigationItem of its children.

There appear to be two scenarios that the container controller needs to handle:

  1. The selected view controller of the container controller changes and therefore the UINavigationItem of the container controller should update itself to mimic the UINavigationItem of the selected view controller.
  2. A child controller updates its UINavigationItem and the container controller must be made aware of the change and update its UINavigationItem to match.

The best solutions I've come up with are:

  1. In the setSelectedViewController: method query the navigation item of the selected view controller and update the leftBarButtonItems, rightBarButtonItems and title properties of the container controller's UINavigationItem to be the same as the selected view controller's UINavigationItem.
  2. In the setSelectedViewController method KVO onto the leftBarButtonItems, rightBarButtonItems and title property of the selected view controller's UINavigationItem and whenever one of those properties changes up the container controller's UINavigationItem.

This is a recurring problem with many of the container controllers that I have written and I can't seem to find any documented solutions to these problems.

What are some solutions people have found to this problem?

jscs
  • 63,694
  • 13
  • 151
  • 195
Reid Main
  • 3,394
  • 3
  • 25
  • 42
  • 2
    Can the container view controller override `- (UINavigationItem *) navigationItem` and just `return selectedViewController.navigationItem;`? – Aaron Brager Aug 01 '13 at 19:48
  • 1
    It couldn't because when the selectedViewController changes the navigationItem would not be called again so the UINavigationController would still be using the UINavigationItem of the previously selected view controller. – Reid Main Aug 01 '13 at 19:54
  • Something like this might work, but I didn't try it: `+ (NSSet *)keyPathsForValuesAffectingNavigationItem { return [NSSet setWithObjects:@"selectedViewController", nil]; }`. That will trigger KVO on navigationItem any time the selectedViewController property changes. I'm not sure if `UINavigationController` is observing `navigationItem` with KVO though. – Aaron Brager Aug 01 '13 at 21:47
  • I don't think the navigationItem property ever changes except for the very first call to that method where it is lazy loaded. Also I don't think KVOing against the navigationItem property on UIViewController will be triggered if you update a property on the UINavigationItem. – Reid Main Aug 02 '13 at 13:28

5 Answers5

6

So the solution that I have currently implemented is to create a category on UIViewController with methods that allow you to set the right bar buttons of that controller's navigation item and then that controller posts a notification letting anyone who cares know that the right bar button items have been changed.

In my container controller I listen for this notification from the currently selected view controller and update the container controller's navigation item accordingly.

In my scenario the container controller overrides the method in the category so that it can keep a local copy of the right bar button items that have been assigned to it and if any notifications are raised it concatenates its right bar button items with its child's and then sends up a notification just incase it is also inside a container controller.

Here is the code that I am using.

UIViewController+ContainerNavigationItem.h

#import <UIKit/UIKit.h>

extern NSString *const UIViewControllerRightBarButtonItemsChangedNotification;

@interface UIViewController (ContainerNavigationItem)

- (void)setRightBarButtonItems:(NSArray *)rightBarButtonItems;
- (void)setRightBarButtonItem:(UIBarButtonItem *)rightBarButtonItem;

@end

UIViewController+ContainerNavigationItem.m

#import "UIViewController+ContainerNavigationItem.h"

NSString *const UIViewControllerRightBarButtonItemsChangedNotification = @"UIViewControllerRightBarButtonItemsChangedNotification";

@implementation UIViewController (ContainerNavigationItem)

- (void)setRightBarButtonItems:(NSArray *)rightBarButtonItems
{
    [[self navigationItem] setRightBarButtonItems:rightBarButtonItems];

    NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter];
    [notificationCenter postNotificationName:UIViewControllerRightBarButtonItemsChangedNotification object:self];
}

- (void)setRightBarButtonItem:(UIBarButtonItem *)rightBarButtonItem
{
    if(rightBarButtonItem != nil)
        [self setRightBarButtonItems:@[ rightBarButtonItem ]];
    else
        [self setRightBarButtonItems:nil];
}

@end

ContainerController.m

- (void)setRightBarButtonItems:(NSArray *)rightBarButtonItems
{
    _rightBarButtonItems = rightBarButtonItems;

    [super setRightBarButtonItems:_rightBarButtonItems];
}

- (void)setSelectedViewController:(UIViewController *)selectedViewController
{
    if(_selectedViewController != selectedViewController)
    {
        if(_selectedViewController != nil)
        {
            // Stop listening for right bar button item changed notification on the view controller.
            NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter];
            [notificationCenter removeObserver:self name:UIViewControllerRightBarButtonItemsChangedNotification object:_selectedViewController];
        }

        _selectedViewController = selectedViewController;

        if(_selectedViewController != nil)
        {
            // Listen for right bar button item changed notification on the view controller.
            NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter];
            [notificationCenter addObserver:self selector:@selector(_childRightBarButtonItemsChanged) name:UIViewControllerRightBarButtonItemsChangedNotification object:_selectedViewController];
        }
    }
}

- (void)_childRightBarButtonItemsChanged
{
    NSArray *childRightBarButtonItems = [[_selectedViewController navigationItem] rightBarButtonItems];

    NSMutableArray *rightBarButtonItems = [NSMutableArray arrayWithArray:_rightBarButtonItems];
    [rightBarButtonItems addObjectsFromArray:childRightBarButtonItems];

    [super setRightBarButtonItems:rightBarButtonItems];
}
Reid Main
  • 3,394
  • 3
  • 25
  • 42
5

I know this question is old, but I think that I found the solution for this problem!

The navigationItem property of a UIViewController is defined in a category/extension in the UINavigationController header file.

This property is defined as:

open var navigationItem: UINavigationItem { get } 

So, as I just found out, you can override the property in the container view controller, in my case:

public override var navigationItem: UINavigationItem {
    return child?.navigationItem ?? super.navigationItem
}

I tried this approach and it's working for me. All buttons, title and views are being shown and updated as they change on the contained view controller.

tomidelucca
  • 2,543
  • 1
  • 26
  • 26
  • Great solution. I think this should now be the preferred approach. – bencallis Apr 07 '20 at 13:13
  • Any idea how the UINavigationController know when the current child changes? In your case do you have only one child at a time (when one is added the previous one is removed)? – androidguy Jun 14 '20 at 06:30
  • yeh the first comment on the question suggests this too but in the second comment the OP says when the child changes they want to return the new child's nav item and it isn't called again. – malhal Jun 14 '20 at 10:25
  • 1
    Great solution! However, the `UINavigationController` queries the `navigationItem` only once. If the child VC is not set at this point in time (e.g. before `viewDidLoad`) the containers `navigationItem` is used. Also the `navigationItem` cannot be updated when the child VC changes. – Andrei Herford Jun 16 '21 at 16:05
2

The accepted answer works, but it breaks the contract on UIViewController, your child controllers are now tightly coupled with your custom category and must use its alternative methods in order to work correctly... I had this issue using the RBStoryboardLink container, and also on a custom tab bar controller of my own, so it was important it would be encapsulated outside of a given container class, so I created a class that has a mirrorVC property (usually set to the container, the one who will listen for notifications) and a few register / unregister methods (for navigationItems, toolbarItems, tabBarItems, as your needs see fit). For example when registering/unregistering for toolbarItems :

static void *myContext = &myContext;
-(void)registerForToolbarItems:(UIViewController*)viewController {
    [viewController addObserver:self forKeyPath:@"toolbarItems" options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld context:myContext];
}
-(void)unregisterForToolbarItems:(UIViewController*)viewController {
    [viewController removeObserver:self forKeyPath:@"toolbarItems" context:myContext];
}

The observe action will handle receiving the new values and forwarding them to the mirrorVC:

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
    if(context == myContext) {
        id newKey = [change objectForKey:NSKeyValueChangeNewKey];
        id oldKey = [change objectForKey:NSKeyValueChangeOldKey];
        //no need to mirror if the value is the same
        if ([newKey isEqual:oldKey]) return;
        //nil values comes packaged in NSNull
        if (newKey == [NSNull null]) newKey = nil;
        //handle each of the possibly registered mirrored properties... 
        if ([keyPath isEqualToString:@"navigationItem.leftBarButtonItem"]) {
            self.mirrorVC.navigationItem.leftBarButtonItem = newKey;
        }
        //...
        //as many more properties as you need forwarded...
        else if ([keyPath isEqualToString:@"toolbarItems"]) {
            [self.mirrorVC setToolbarItems:newKey animated:YES];
        }
    }
    else {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

Then in your container, at the right moments, you register and unregister

[_selectedViewController unregister...]
_selectedViewController = selectedViewController;
[_selectedViewController register...]

You must be aware of a potential pitfall though: not all desirable properties are KVO compliant, and the ones that do aren't documented to be - so they can stop being or misbehave at any time. The toolbarItems property, for example, is not. I created a UIViewController category based on this gist ( https://gist.github.com/brentdax/5938102 ) that enables KVO notifications for it so it works in this scenario. Note: the gist above wasn't necessary for UINavigationItem, iOS 5~7 sends out proper KVO notifications for it, with that category I would get double notifications for UINavigationItems. It worked flawlessly for toolbarItems!

Rafael Nobre
  • 5,062
  • 40
  • 40
  • 1
    UINavigationItem does not appear to be KVO complaint for iOS 7. I used `[_selectedViewController addObserver:self forKeyPath:@"navigationItem.rightBarButtonItems" options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew context:nil];` to observe changes to the right bar button items on the navigation item and it only fires once when they are initially set. – Reid Main Nov 26 '13 at 16:41
  • 4
    Upon further investigation it appears it is KVO compliant if you call the setRightBarButtonItems method. If you call the animated method e.g. `[[self navigationItem] setRightBarButtonItems:@[ addBarButtonItem, editBarButtonItem ] animated:YES];` then it doesn't work properly. – Reid Main Nov 26 '13 at 16:47
0

Have you considered NOT wrapping your container view controller in a UINavigationController and just adding a UINavigationBar to your view? Then you can push your child view controller's navigation items directly to that navigation bar. Essentially your container view controller would replace a normal UIViewController.

Patrick Tescher
  • 3,387
  • 1
  • 18
  • 31
  • Unfortunately that is not a possibility because the flow of the app is built around being able to push view controllers onto a navigation controller. If any of the rows in the table view are selected a "detail" view controller is then pushed onto the navigation stack. Also the child view controller shouldn't need to know that it is inside a container controller. It should just set its UINavigationItem and work if it was inside a UINavigationController OR inside any container controller. – Reid Main Aug 01 '13 at 19:37
  • So the user can essentially push all the child views inside of the navigation stack? Then I can load 10 view controllers, and when I start going back I'm in other places of the app? Sounds confusing, not only to us, but to the user. – Can Aug 01 '13 at 22:00
  • No. Imagine you can swipe back and forth between a list of items for "today", "tomorrow" and "2 days in the future". This is the container controller. If you press on any of the items in that list you push a new UIViewController onto the stack which gets put ON TOP of the container controller. If you press the back button you are then taken back to the container controller at the exact position you were when you selected the item. This is the container controller used in theScore app which I am working on. We just never needed items in the navigation bar until now. – Reid Main Aug 02 '13 at 13:21
  • You could still use a custom UINavigation bar and then hide and show the UINavigationController's navigation bar when you need to. – Patrick Tescher Aug 05 '13 at 17:20
  • So you are suggesting that my container controller add a UINavigationBar to its own view and be responsible for hiding the navigation bar of any UINavigationController it is a part of? – Reid Main Aug 06 '13 at 17:06
  • Essentially, yes. If you have your own UINavigationBar you can do all sorts of things with it. The one that comes with a UINavigationController is much more limited. – Patrick Tescher Aug 06 '13 at 17:16
-2

I know this is an old thread, but I just ran into this issue and thought someone else might as well.

So for future reference, I did it as follows: I sent a block to the child view controller, which just sets the parent's UINavigationItem's right button. Then I created a UIBarButtonItem as normal in the child view controller, calling some method in that same controller.

So, in ChildViewController.h:

// Declare block property
@property (nonatomic, copy) void (^setRightBarButtonBlock)(UIBarButtonItem*);

And in ChildViewController.m:

self.myBarButton = [[UIBarButtonItem alloc] 
    initWithTitle:@"My Title" 
    style:UIBarButtonItemStylePlain 
    target:self 
    action:@selector(didPressMyBarButton:)];

...

// Show bar button in navigation bar
// As normal, just call it with 'nil' to hide the button
if (self.setRightBarButtonBlock) {
    self.setRightBarButtonBlock(self.myBarButton);
}

...

- (void)didPressMyBarButton:(UIBarButtonItem *)sender {
    // Do something here
}

And finally in ParentViewController.m

// Initialise child view controller
ChildViewController *child = [[ChildViewController alloc] init];

// Give it block for changing bar button item
__weak typeof(self) weakSelf = self;
child.setRightBarButtonBlock = ^void(UIBarButtonItem *barButtonItem) {
    [weakSelf.navigationItem setRightBarButtonItem:barButtonItem animated:YES];
};

// Finish the parent-child VC dance

That's it. This feels good to me because it keeps the logic pertaining to the UIBarButtonItem in the view controller which is actually interested in it.

Note: I should mention that I am not a pro. This may just be a terrible way to do it. But it seems to work just fine.

erwald
  • 162
  • 1
  • 9