27
votes

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?

5
Can the container view controller override - (UINavigationItem *) navigationItem and just return selectedViewController.navigationItem;?Aaron Brager
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
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
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

5 Answers

6
votes

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];
}
3
votes

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.

2
votes

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!

0
votes

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.

-1
votes

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.