24
votes

I have a table view controller wrapped in a navigation controller. The navigation controller seems to automatically apply the correct content inset to the table view controller when it is presented via presentViewController:animated:completion:. (Can anyone explain to me how this works exactly?)

However, as soon as I wrap the combination in a custom container view controller and present that instead, the topmost part of the table view content is hidden behind the navigation bar. Is there anything I can do in order to preserve the automatic content inset behaviour in this configuration? Do I have to "pass through" something in the container view controller for this to work correctly?

I'd like to avoid having to adjust the content inset manually or via Auto Layout as I want to continue supporting iOS 5.

6

6 Answers

12
votes

I've had a similar problem. So on iOS 7, there is a new property on UIViewController called automaticallyAdjustsScrollViewInsets. By default, this is set to YES. When your view hierarchy is in any way more complicated than a scroll/table view inside a navigation or tab bar controller, this property didn't seem to work for me.

What I did was this: I created a base view controller class that all my other view controllers inherit from. That view controller then takes care of explicitly setting the insets:

Header:

#import <UIKit/UIKit.h>

@interface SPWKBaseCollectionViewController : UICollectionViewController

@end

Implementation:

#import "SPWKBaseCollectionViewController.h"

@implementation SPWKBaseCollectionViewController


- (void)viewDidLoad
{
    [super viewDidLoad];

    [self updateContentInsetsForInterfaceOrientation:self.interfaceOrientation];
}

- (void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration
{
    [self updateContentInsetsForInterfaceOrientation:toInterfaceOrientation];

    [super willRotateToInterfaceOrientation:toInterfaceOrientation duration:duration];
}

- (void)updateContentInsetsForInterfaceOrientation:(UIInterfaceOrientation)orientation
{
    if ([[[UIDevice currentDevice] systemVersion] floatValue] >= 7) {
        UIEdgeInsets insets;

        if ([[UIDevice currentDevice] userInterfaceIdiom] == UIUserInterfaceIdiomPad) {
            insets = UIEdgeInsetsMake(64, 0, 56, 0);
        } else if ([[UIDevice currentDevice] userInterfaceIdiom] == UIUserInterfaceIdiomPhone) {
            if (UIInterfaceOrientationIsPortrait(orientation)) {
                insets = UIEdgeInsetsMake(64, 0, 49, 0);
            } else {
                insets = UIEdgeInsetsMake(52, 0, 49, 0);
            }
        }

        self.collectionView.contentInset = insets;
        self.collectionView.scrollIndicatorInsets = insets;
    }
}

@end

This also works for web views:

self.webView.scrollView.contentInset = insets;
self.webView.scrollView.scrollIndicatorInsets = insets;

If there is a more elegant and yet reliable way to do this, please let me know! The hardcoded inset values smell pretty bad, but I don't really see another way when you want to keep iOS 5 compatibility.

5
votes

FYI in case anyone is having a similar problem: it appears that automaticallyAdjustsScrollViewInsets is only applied if your scrollview (or tableview/collectionview/webview) is the first view in their view controller's hierarchy.

I often add a UIImageView first in my hierarchy in order to have a background image. If you do this, you have to manually set the edge insets of the scrollview in viewDidLayoutSubviews:

- (void) viewDidLayoutSubviews {
    CGFloat top = self.topLayoutGuide.length;
    CGFloat bottom = self.bottomLayoutGuide.length;
    UIEdgeInsets newInsets = UIEdgeInsetsMake(top, 0, bottom, 0);
    self.collectionView.contentInset = newInsets;

}
4
votes

Thanks to Johannes Fahrenkrug's hint, I figured out the following: automaticallyAdjustsScrollViewInsets indeed only seems to work as advertised when the child view controller's root view is a UIScrollView. For me, this means the following arrangement works:

Content view controller inside Navigation controller inside Custom container view controller.

While this doesn't:

Content view controller inside Custom container view controller inside Navigation controller.

The second option, however, seems more sensible from a logical point of view. The first option probably only works because the custom container view controller is used to affix a view to the bottom of the content. If I wanted to put the view between the navigation bar and the content, it wouldn't work that way.

3
votes

UINavigationController calls undocumented method _updateScrollViewFromViewController:toViewController in transition between controllers to update content insets. Here is my solution:

UINavigationController+ContentInset.h

#import <UIKit/UIKit.h>
@interface UINavigationController (ContentInset)
- (void) updateScrollViewFromViewController:(UIViewController*) from toViewController:(UIViewController*) to;
@end

UINavigationController+ContentInset.m

#import "UINavigationController+ContentInset.h"
@interface UINavigationController()
- (void) _updateScrollViewFromViewController:(UIViewController*) from toViewController:(UIViewController*) to;
@end

@implementation UINavigationController (ContentInset)
- (void) updateScrollViewFromViewController:(UIViewController*) from toViewController:(UIViewController*) to {
    if ([UINavigationController instancesRespondToSelector:@selector(_updateScrollViewFromViewController:toViewController:)])
        [self _updateScrollViewFromViewController:from toViewController:to];
}
@end

Call updateScrollViewFromViewController:toViewController somewhere in your custom container view controller

[self addChildViewController:newContentViewController];
[self transitionFromViewController:previewsContentViewController
                  toViewController:newContentViewController
                          duration:0.5f
                           options:0
                        animations:^{
                            [self.navigationController updateScrollViewFromViewController:self toViewController:newContentViewController];

                    }
                    completion:^(BOOL finished) {
                        [newContentViewController didMoveToParentViewController:self];
                    }];
3
votes

You don't need to call any undocumented methods. All you need to do is call setNeedsLayout on your UINavigationController. See this other answer: https://stackoverflow.com/a/33344516/5488931

2
votes

Swift Solution

This solution targets iOS 8 and above in Swift.

My app's root view controller is a navigation controller. Initially this navigation controller has one UIViewController on its stack which I will call the parent view controller.

When the user taps a nav bar button, the parent view controller toggles between two child view controllers on its view using the containment API (toggling between mapped results and listed results). The parent view controller holds references to the two child view controllers. The second child view controller is not created until the first time the user taps the toggle button. The second child view controller is a table view controller and exhibited the issue where it underlaps the navigation bar regardless of how its automaticallyAdjustsScrollViewInsets property was set.

To fix this I call adjustChild:tableView:insetTop (shown below) after the child table view controller is created and in the parent view controller's viewDidLayoutSubviews method.

The child view controller's table view and the parent view controller's topLayoutGuide.length are passed to the adjustChild:tableView:insetTop method like this...

// called right after childViewController is created
adjustChild(childViewController.tableView, insetTop: topLayoutGuide.length)

The adjustChild method...

private func adjustChild(tableView: UITableView, insetTop: CGFloat) {
    tableView.contentInset.top = insetTop
    tableView.scrollIndicatorInsets.top = insetTop

    // make sure the tableview is scrolled at least as far as insetTop
    if (tableView.contentOffset.y > -insetTop) {
        tableView.contentOffset.y = -insetTop
    }
}

The statement tableView.scrollIndicatorInsets.top = insetTop adjusts the scroll indicator on the right side of the table view so it begins just below the navigation bar. This is subtle and is easily overlooked until you become aware of it.

The following is what viewDidLayoutSubviews looks like...

override func viewDidLayoutSubviews() {
    if let childViewController = childViewController {
        adjustChild(childViewController.tableView, insetTop: topLayoutGuide.length)
    }
}

Note that all the code above appears in the parent view controller.

I figured this out with help from Christopher Pickslay's answer to the question "iOS 7 Table view fail to auto adjust content inset".