57

UPDATE

Based on Tim's answer, I implemented the following in each view controller that had a scrollview (or subclass) that was part of my custom container:

- (void)didMoveToParentViewController:(UIViewController *)parent
{
    if (parent) {
        CGFloat top = parent.topLayoutGuide.length;
        CGFloat bottom = parent.bottomLayoutGuide.length;

        // this is the most important part here, because the first view controller added 
        // never had the layout issue, it was always the second. if we applied these
        // edge insets to the first view controller, then it would lay out incorrectly.
        // first detect if it's laid out correctly with the following condition, and if
        // not, manually make the adjustments since it seems like UIKit is failing to do so
        if (self.collectionView.contentInset.top != top) {
            UIEdgeInsets newInsets = UIEdgeInsetsMake(top, 0, bottom, 0);
            self.collectionView.contentInset = newInsets;
            self.collectionView.scrollIndicatorInsets = newInsets;
        }
    }

    [super didMoveToParentViewController:parent];
}

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

I have custom container view controller called SegmentedPageViewController. I set this as a UINavigationController's rootViewController.

The purpose of SegmentedPageViewController is to allow a UISegmentedControl, set as the NavController's titleView, to switch between different child view controllers.

enter image description here

These child view controllers all contain either a scrollview, tableview, or collection view.

We're finding that the first view controller loads fine, correctly positioned underneath the navigation bar. But when we switch to a new view controller, the navbar isn't respected and the view is set underneath the nav bar.

enter image description here

We're using auto layout and interface builder. We've tried everything we can think of, but can't find a consistent solution.

Here's the main code block responsible for setting the first view controller and switching to another one when a user taps on the segmented control:

- (void)switchFromViewController:(UIViewController *)oldVC toViewController:(UIViewController *)newVC
{
    if (newVC == oldVC) return;

    // Check the newVC is non-nil otherwise expect a crash: NSInvalidArgumentException
    if (newVC) {

        // Set the new view controller frame (in this case to be the size of the available screen bounds)
        // Calulate any other frame animations here (e.g. for the oldVC)
        newVC.view.frame = self.view.bounds;

        // Check the oldVC is non-nil otherwise expect a crash: NSInvalidArgumentException
        if (oldVC) {
            // **** THIS RUNS WHEN A NEW VC IS SET ****
            // DIFFERENT FROM FIRST VC IN THAT WE TRANSITION INSTEAD OF JUST SETTING


            // Start both the view controller transitions
            [oldVC willMoveToParentViewController:nil];
            [self addChildViewController:newVC];

            // Swap the view controllers
            // No frame animations in this code but these would go in the animations block
            [self transitionFromViewController:oldVC
                              toViewController:newVC
                                      duration:0.25
                                       options:UIViewAnimationOptionLayoutSubviews
                                    animations:^{}
                                    completion:^(BOOL finished) {
                                        // Finish both the view controller transitions
                                        [oldVC removeFromParentViewController];
                                        [newVC didMoveToParentViewController:self];
                                        // Store a reference to the current controller
                                        self.currentViewController = newVC;
                                    }];
        } else {

            // **** THIS RUNS WHEN THE FIRST VC IS SET ****
            // JUST STANDARD VIEW CONTROLLER CONTAINMENT

            // Otherwise we are adding a view controller for the first time
            // Start the view controller transition
            [self addChildViewController:newVC];

            // Add the new view controller view to the view hierarchy
            [self.view addSubview:newVC.view];

            // End the view controller transition
            [newVC didMoveToParentViewController:self];

            // Store a reference to the current controller
            self.currentViewController = newVC;
        }
    }

}
Irfan
  • 4,301
  • 6
  • 29
  • 46
djibouti33
  • 12,102
  • 9
  • 83
  • 116
  • Is the height constraint properly set? – ijason03 Sep 26 '13 at 21:42
  • 1
    i assume so. the weird thing is, it's always the first one that loads fine, and the second that loads incorrectly. if i switch it so the 2nd segment is selected when the app loads, that loads fine, and the first segment loads incorrectly, opposite of the images i've used for examples here. – djibouti33 Sep 26 '13 at 21:46
  • although i do not have an explicit height constraint for the table view (in vc 1) or the collection view (in vc2). in the nib, they're just set to take the full height of their view. in fact, those two xibs have no constraints applied explicitly. – djibouti33 Sep 26 '13 at 21:47
  • 1
    I was having this same issue and it was driving me crazy. Thanks for taking the time to find out and post your solution. I wasn't able to figure this out on my own. Thanks! – Juan González Dec 29 '13 at 09:16
  • Great answer! Just make sure you call super when you override didMoveToParentViewController. I've also filed a radar with Apple: http://openradar.appspot.com/16042338 – brynbodayle Feb 11 '14 at 23:48
  • I found setting the insets inside the `didMoveToParentViewController` method are actually visible to the user; you can see the view 'jump' down a bit after it has appeared. Changing the insets before you call the `transitionFromViewController` works too, the change is not visible, and you don't have to implement the `didMoveToParentViewController` method on all child view controllers. – René Jul 15 '14 at 09:24
  • If you add a `UITabBarController` into the mix, each tab an instance of the segmented controller, it complicates things. Without adding the `-didMove…` code, the insets fix themselves if you switch segments (looks wrong), then switch tabs and switch back (insets fixed!). If you do have the `-didMove…` code in place, it will overcompensate and add extra insets to the top and bottom of the scroll view. – macserv Jan 26 '15 at 22:12
  • As @René said there's a visible jump when doing this in `didMoveToParentViewController` - using `willMoveToParentViewController` instead seems to achieve the same effect before the incoming view is rendered, so the jump is not visible. – Craig Jun 23 '15 at 21:37
  • @Craig I had the same "jumping" problem, but setting the insets of my tableView in willMoveToParentViewController will cause a crash. The tableView is nil at that time. Any ideas why? – paolo Jun 24 '15 at 21:57

5 Answers5

27

Your custom container view controller will need to adjust the contentInset of the second view controller according to your known navigation bar height, respecting the automaticallyAdjustsScrollViewInsets property of the child view controller. (You may also be interested in the topLayoutGuide property of your container - make sure it returns the right value during and after the view switch.)

UIKit is remarkably inconsistent (and buggy) in how it applies this logic; sometimes you'll see it perform this adjustment automatically for you by reaching multiple view controllers down in the hierarchy, but often after a custom container switch you'll need to do the work yourself.

Tim
  • 59,527
  • 19
  • 156
  • 165
  • I'm not clear how to do what you're suggesting, mainly because I don't want to assume that my child has a scrollview. I'm using my SegmentedPageController in various places in my app, and I don't want to assume my parent knows everything about the child. If the parent should, I'm not clear how I do that. One thing I tried: in my child view controllers, I overrode -willMoveToParentVC: and set `self.tableView.contentInset = UIEdgeInsetsMake(parent.topLayoutGuide.length, 0, 0, 0);` If this is the second child loaded, it's great, but if it's the first, it's still incorrect like it was originally. – djibouti33 Sep 27 '13 at 04:50
  • 1
    Hmm - you have a good point. In your child, try moving that logic to `-viewWillLayoutSubviews` - I've found that the `topLayoutGuide` is not always consistent by `-willMoveToParentViewController:`. – Tim Sep 27 '13 at 16:12
  • If that doesn't work, you may need to establish some sort of scroll-view-search approach where you walk the child's view hierarchy looking for something whose `contentInset` you can adjust. (In my own custom containers, I make children conform to a protocol that declares a method which returns the scroll view acting as the main content view, if such a thing exists, to ease this search.) – Tim Sep 27 '13 at 16:13
  • 2
    I updated my question. Turns out it wasn't about using viewWillLayout.. or willMoveToParentVC. It was about realizing that UIKit got it right the first time, and failed afterwards. Wrapping the adjustments in a conditional that first checked if it needed the adjustment was the ticket. Also, with this solution, the children are responsible for themselves, and the container vc doesn't worry about who it's displaying. If that's bad, you could do the same thing in viewWillLayout… in the container, and set the insets after finding the scroll view using either of the approaches you suggested. – djibouti33 Sep 27 '13 at 21:01
  • Your explanation of UIKit's handling of custom containers was what I was looking to confirm. Thanks! – Bob Spryn Oct 03 '13 at 00:11
24

This seems to all be a lot simpler than what people make out.

UINavigationController will set scrollview insets only while laying out subviews. addChildViewController: does not cause a layout, however, so after calling it, you just need to call setNeedsLayout on your navigationController. Here's what I do while switching views in a custom tab-like view:

[self addChildViewController:newcontroller];
[self.view insertSubview:newview atIndex:0];
[self.navigationController.view setNeedsLayout];

The last line will cause scrollview insets to be re-calculated for the new view controller's contents.

Dag Ågren
  • 1,064
  • 7
  • 18
  • 1
    Works as you said! – tperei Apr 09 '16 at 02:33
  • This works, it really saved me. But I'm wondering why `self.navigationController.view` works and `self.view` doesn't. – NiñoScript Aug 24 '16 at 17:23
  • Layout out self.view only lays out that and its subviews. The navigation controller that sets the insets is a superview, so its layout method is never called. (The view it sets the insets on is a subview, which is confusing, but the code that actually does the work is in the navigation controller.) – Dag Ågren Aug 25 '16 at 19:56
  • This worked along with making sure the added subview is on top of the stack. – Gurpartap Singh Sep 03 '16 at 07:55
  • What do you mean by "the added subview is on top of the stack" ? – koen Feb 26 '17 at 21:39
  • 1
    Presumably, the same as what my original code does with `[self.view insertSubview:newview atIndex:0];`. Make sure the view of the added view controller is the first in the list of views. – Dag Ågren Feb 27 '17 at 11:10
  • This needs to be way more visible. Can remove a bunch of code that is iOS 10 specific and was very ugly. Thanks! – Jonathan. Jul 25 '18 at 10:33
9

FYI in case anyone is having a similar problem: this issue can occur even without embedded view controllers. 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;

}
BFar
  • 2,447
  • 22
  • 23
  • This is a good solution-- probably similar to what UIKit is doing automatically. – Nick Lee Jul 20 '15 at 20:26
  • Be aware that if you're doing this in a `UITableViewController`, this will prevent the controller to automatically update the content inset whenever the keyboard is shown. – PatrickNLT Jan 28 '16 at 15:07
1

I found a better solution,use the undocumented method of UINavigationController.

#import <UIKit/UIKit.h>

@interface UINavigationController (ContentInset)


- (void) computeAndApplyScrollContentInsetDeltaForViewController:(UIViewController*) controller;

@end

#import "UINavigationController+ContentInset.h"

@interface UINavigationController()


- (void)_computeAndApplyScrollContentInsetDeltaForViewController:(id)arg1; 

@end


@implementation UINavigationController (ContentInset)

- (void) computeAndApplyScrollContentInsetDeltaForViewController:(UIViewController*) controller
{
    if ([UINavigationController instancesRespondToSelector:@selector(_computeAndApplyScrollContentInsetDeltaForViewController:)])
        [self _computeAndApplyScrollContentInsetDeltaForViewController:controller];
}

@end

then,do like this

- (void) cycleFromViewController: (UIViewController*) oldC
                toViewController: (UIViewController*) newC
{
    [oldC willMoveToParentViewController:nil];                        
    [self addChildViewController:newC];
  
    [self transitionFromViewController: oldC toViewController: newC   
                              duration: 0.25 options:0
                            animations:^{
                                newC.view.frame = oldC.view.frame;                                    
                                [self.navigationController computeAndApplyScrollContentInsetDeltaForViewController:newC];
                            }
                            completion:^(BOOL finished) {
                                [oldC removeFromParentViewController];                  
                                [newC didMoveToParentViewController:self];
                            }];
}
liaofc
  • 11
  • 1
0

Setting edgesForExtendedLayout = [] on my childcontrollers worked for me.

coyer
  • 4,122
  • 3
  • 28
  • 35