31

I'm implementing a custom container which is pretty similar to UINavigationController except for it does not hold the whole controller stack. It has a UINavigationBar which is constrained to the container controller's topLayoutGuide, which happens to be 20px off the top, which is OK.

When I add a child view controller and put its view into the hierarchy I want its topLayoutGuide seen in IB and used for laying out the child view controller's view's subviews to appear at the bottom of my navigation bar. There is a note of what is to be done in the relevant documentation:

The value of this property is, specifically, the value of the length property of the object returned when you query this property. This value is constrained by either the view controller or by its enclosing container view controller (such as a navigation or tab bar controller), as follows:

  • A view controller not within a container view controller constrains this property to indicate the bottom of the status bar, if visible,
    or else to indicate the top edge of the view controller's view.
  • A view controller within a container view controller does not set this property's value. Instead, the container view controller constrains the value to indicate:
    • The bottom of the navigation bar, if a navigation bar is visible
    • The bottom of the status bar, if only a status bar is visible
    • The top edge of the view controller’s view, if neither a status bar nor navigation bar is visible

But I don't quite understand how to "constrain it's value" since both the topLayoutGuide and it's length properties are readonly.

I've tried this code for adding a child view controller:

[self addChildViewController:gamePhaseController];
UIView *gamePhaseControllerView = gamePhaseController.view;
gamePhaseControllerView.translatesAutoresizingMaskIntoConstraints = NO;
[self.contentContainer addSubview:gamePhaseControllerView];

NSArray *horizontalConstraints = [NSLayoutConstraint constraintsWithVisualFormat:@"|-0-[gamePhaseControllerView]-0-|"
                                                                         options:0
                                                                         metrics:nil
                                                                           views:NSDictionaryOfVariableBindings(gamePhaseControllerView)];

NSLayoutConstraint *topLayoutGuideConstraint = [NSLayoutConstraint constraintWithItem:gamePhaseController.topLayoutGuide
                                                                            attribute:NSLayoutAttributeTop
                                                                            relatedBy:NSLayoutRelationEqual
                                                                               toItem:self.navigationBar
                                                                            attribute:NSLayoutAttributeBottom
                                                                           multiplier:1 constant:0];
NSLayoutConstraint *bottomLayoutGuideConstraint = [NSLayoutConstraint constraintWithItem:gamePhaseController.bottomLayoutGuide
                                                                               attribute:NSLayoutAttributeBottom
                                                                               relatedBy:NSLayoutRelationEqual
                                                                                  toItem:self.bottomLayoutGuide
                                                                               attribute:NSLayoutAttributeTop
                                                                              multiplier:1 constant:0];
[self.view addConstraint:topLayoutGuideConstraint];
[self.view addConstraint:bottomLayoutGuideConstraint];
[self.contentContainer addConstraints:horizontalConstraints];
[gamePhaseController didMoveToParentViewController:self];

_contentController = gamePhaseController;

In the IB I specify "Under Top Bars" and "Under Bottom Bars" for the gamePhaseController. One of the views is specifically constrained to the top layout guide, anyway on the device it appears to be 20px off the the bottom of the container's navigation bar...

What is the right way of implementing a custom container controller with this behavior?

Danchoys
  • 739
  • 1
  • 8
  • 14
  • As I understand the documentation you quoted, the correct way of dealing with this scenario would be overwrite `topLayoutGuide` in the container view controller and returning the appropriate y value. Unfortunately this does not seem to be possible, as @Stefan Fisk pointed out. I'd consider this a bug (or rather a missing feature) on apple's side. – de. Jan 30 '14 at 13:57
  • This works fine on iOS 8. Any idea what exactly has changed ? – Petar Jan 19 '15 at 11:24
  • Still doesn't work for me. System tends to set zero height and zero top offset constraints for the child view controller's top layout guide. It may now be easily confirmed using view hierarchy debugging in Xcode 6. Adding more constraints will create the "unsatisfiable constraints" situation. – Danchoys Mar 12 '15 at 09:36
  • Override topLayoutGuide and supply the class shown in this answer http://stackoverflow.com/a/33215299/259521 – malhal Jan 11 '16 at 00:47

4 Answers4

30

As far as I have been able to tell after hours of debugging, the layout guides are readonly, and derived from the private classes used for constraints based layout. Overriding the accessors does nothing (even though they are called), and it's all just craptastically annoying.

Stefan Fisk
  • 1,563
  • 13
  • 19
  • please note that my answer is based on work I did in December, things might have changed in later versions. – Stefan Fisk Mar 12 '14 at 21:28
  • 2
    Unfortunately, this is the right answer. I've logged a radar ("Feature Request: override layoutGuides"), feel free to dupe it: http://openradar.appspot.com/21123507 – Ortwin Gentz May 27 '15 at 17:12
  • the property is readonly, but you can simply pass it to he child, so it can do custom adjustments, see my answer bellow – Peter Lapisu May 26 '17 at 13:52
5

(UPDATE: now available as cocoapod, see https://github.com/stefreak/TTLayoutSupport)

A working solution is to remove apple's layout constraints and add your own constraints. I made a little category for this.

Here is the code - but I suggest the cocoapod. It's got unit tests and is more likely to be up to date.

//
//  UIViewController+TTLayoutSupport.h
//
//  Created by Steffen on 17.09.14.
//

#import <UIKit/UIKit.h>

@interface UIViewController (TTLayoutSupport)

@property (assign, nonatomic) CGFloat tt_bottomLayoutGuideLength;

@property (assign, nonatomic) CGFloat tt_topLayoutGuideLength;

@end

-

#import "UIViewController+TTLayoutSupport.h"
#import "TTLayoutSupportConstraint.h"
#import <objc/runtime.h>

@interface UIViewController (TTLayoutSupportPrivate)

// recorded apple's `UILayoutSupportConstraint` objects for topLayoutGuide
@property (nonatomic, strong) NSArray *tt_recordedTopLayoutSupportConstraints;

// recorded apple's `UILayoutSupportConstraint` objects for bottomLayoutGuide
@property (nonatomic, strong) NSArray *tt_recordedBottomLayoutSupportConstraints;

// custom layout constraint that has been added to control the topLayoutGuide
@property (nonatomic, strong) TTLayoutSupportConstraint *tt_topConstraint;

// custom layout constraint that has been added to control the bottomLayoutGuide
@property (nonatomic, strong) TTLayoutSupportConstraint *tt_bottomConstraint;

// this is for NSNotificationCenter unsubscription (we can't override dealloc in a category)
@property (nonatomic, strong) id tt_observer;

@end

@implementation UIViewController (TTLayoutSupport)

- (CGFloat)tt_topLayoutGuideLength
{
    return self.tt_topConstraint ? self.tt_topConstraint.constant : self.topLayoutGuide.length;
}

- (void)setTt_topLayoutGuideLength:(CGFloat)length
{
    [self tt_ensureCustomTopConstraint];

    self.tt_topConstraint.constant = length;

    [self tt_updateInsets:YES];
}

- (CGFloat)tt_bottomLayoutGuideLength
{
    return self.tt_bottomConstraint ? self.tt_bottomConstraint.constant : self.bottomLayoutGuide.length;
}

- (void)setTt_bottomLayoutGuideLength:(CGFloat)length
{
    [self tt_ensureCustomBottomConstraint];

    self.tt_bottomConstraint.constant = length;

    [self tt_updateInsets:NO];
}

- (void)tt_ensureCustomTopConstraint
{
    if (self.tt_topConstraint) {
        // already created
        return;
    }

    // recording does not work if view has never been accessed
    __unused UIView *view = self.view;
    // if topLayoutGuide has never been accessed it may not exist yet
    __unused id<UILayoutSupport> topLayoutGuide = self.topLayoutGuide;

    self.tt_recordedTopLayoutSupportConstraints = [self findLayoutSupportConstraintsFor:self.topLayoutGuide];
    NSAssert(self.tt_recordedTopLayoutSupportConstraints.count, @"Failed to record topLayoutGuide constraints. Is the controller's view added to the view hierarchy?");
    [self.view removeConstraints:self.tt_recordedTopLayoutSupportConstraints];

    NSArray *constraints =
        [TTLayoutSupportConstraint layoutSupportConstraintsWithView:self.view
                                                     topLayoutGuide:self.topLayoutGuide];

    // todo: less hacky?
    self.tt_topConstraint = [constraints firstObject];

    [self.view addConstraints:constraints];

    // this fixes a problem with iOS7.1 (GH issue #2), where the contentInset
    // of a scrollView is overridden by the system after interface rotation
    // this should be safe to do on iOS8 too, even if the problem does not exist there.
    __weak typeof(self) weakSelf = self;
    self.tt_observer = [[NSNotificationCenter defaultCenter] addObserverForName:UIDeviceOrientationDidChangeNotification
                                                                         object:nil
                                                                          queue:[NSOperationQueue mainQueue]
                                                                     usingBlock:^(NSNotification *note) {
        __strong typeof(self) self = weakSelf;
        [self tt_updateInsets:NO];
    }];
}

- (void)tt_ensureCustomBottomConstraint
{
    if (self.tt_bottomConstraint) {
        // already created
        return;
    }

    // recording does not work if view has never been accessed
    __unused UIView *view = self.view;
    // if bottomLayoutGuide has never been accessed it may not exist yet
    __unused id<UILayoutSupport> bottomLayoutGuide = self.bottomLayoutGuide;

    self.tt_recordedBottomLayoutSupportConstraints = [self findLayoutSupportConstraintsFor:self.bottomLayoutGuide];
    NSAssert(self.tt_recordedBottomLayoutSupportConstraints.count, @"Failed to record bottomLayoutGuide constraints. Is the controller's view added to the view hierarchy?");
    [self.view removeConstraints:self.tt_recordedBottomLayoutSupportConstraints];

    NSArray *constraints =
    [TTLayoutSupportConstraint layoutSupportConstraintsWithView:self.view
                                              bottomLayoutGuide:self.bottomLayoutGuide];

    // todo: less hacky?
    self.tt_bottomConstraint = [constraints firstObject];

    [self.view addConstraints:constraints];
}

- (NSArray *)findLayoutSupportConstraintsFor:(id<UILayoutSupport>)layoutGuide
{
    NSMutableArray *recordedLayoutConstraints = [[NSMutableArray alloc] init];

    for (NSLayoutConstraint *constraint in self.view.constraints) {
        // I think an equality check is the fastest check we can make here
        // member check is to distinguish accidentally created constraints from _UILayoutSupportConstraints
        if (constraint.firstItem == layoutGuide && ![constraint isMemberOfClass:[NSLayoutConstraint class]]) {
            [recordedLayoutConstraints addObject:constraint];
        }
    }

    return recordedLayoutConstraints;
}

- (void)tt_updateInsets:(BOOL)adjustsScrollPosition
{
    // don't update scroll view insets if developer didn't want it
    if (!self.automaticallyAdjustsScrollViewInsets) {
        return;
    }

    UIScrollView *scrollView;

    if ([self respondsToSelector:@selector(tableView)]) {
        scrollView = ((UITableViewController *)self).tableView;
    } else if ([self respondsToSelector:@selector(collectionView)]) {
        scrollView = ((UICollectionViewController *)self).collectionView;
    } else {
        scrollView = (UIScrollView *)self.view;
    }

    if ([scrollView isKindOfClass:[UIScrollView class]]) {
        CGPoint previousContentOffset = CGPointMake(scrollView.contentOffset.x, scrollView.contentOffset.y + scrollView.contentInset.top);

        UIEdgeInsets insets = UIEdgeInsetsMake(self.tt_topLayoutGuideLength, 0, self.tt_bottomLayoutGuideLength, 0);
        scrollView.contentInset = insets;
        scrollView.scrollIndicatorInsets = insets;

        if (adjustsScrollPosition && previousContentOffset.y == 0) {
            scrollView.contentOffset = CGPointMake(previousContentOffset.x, -scrollView.contentInset.top);
        }
    }
}

@end

@implementation UIViewController (TTLayoutSupportPrivate)

- (NSLayoutConstraint *)tt_topConstraint
{
    return objc_getAssociatedObject(self, @selector(tt_topConstraint));
}

- (void)setTt_topConstraint:(NSLayoutConstraint *)constraint
{
    objc_setAssociatedObject(self, @selector(tt_topConstraint), constraint, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (NSLayoutConstraint *)tt_bottomConstraint
{
    return objc_getAssociatedObject(self, @selector(tt_bottomConstraint));
}

- (void)setTt_bottomConstraint:(NSLayoutConstraint *)constraint
{
    objc_setAssociatedObject(self, @selector(tt_bottomConstraint), constraint, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (NSArray *)tt_recordedTopLayoutSupportConstraints
{
    return objc_getAssociatedObject(self, @selector(tt_recordedTopLayoutSupportConstraints));
}

- (void)setTt_recordedTopLayoutSupportConstraints:(NSArray *)constraints
{
    objc_setAssociatedObject(self, @selector(tt_recordedTopLayoutSupportConstraints), constraints, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (NSArray *)tt_recordedBottomLayoutSupportConstraints
{
    return objc_getAssociatedObject(self, @selector(tt_recordedBottomLayoutSupportConstraints));
}

- (void)setTt_recordedBottomLayoutSupportConstraints:(NSArray *)constraints
{
    objc_setAssociatedObject(self, @selector(tt_recordedBottomLayoutSupportConstraints), constraints, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (void)setTt_observer:(id)tt_observer
{
    objc_setAssociatedObject(self, @selector(tt_observer), tt_observer, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (id)tt_observer
{
    return objc_getAssociatedObject(self, @selector(tt_observer));
}

-

//
//  TTLayoutSupportConstraint.h
//
//  Created by Steffen on 17.09.14.
//

#import <UIKit/UIKit.h>

@interface TTLayoutSupportConstraint : NSLayoutConstraint

+ (NSArray *)layoutSupportConstraintsWithView:(UIView *)view topLayoutGuide:(id<UILayoutSupport>)topLayoutGuide;

+ (NSArray *)layoutSupportConstraintsWithView:(UIView *)view bottomLayoutGuide:(id<UILayoutSupport>)bottomLayoutGuide;

@end

-

//
//  TTLayoutSupportConstraint.m
// 
//  Created by Steffen on 17.09.14.
//

#import "TTLayoutSupportConstraint.h"

@implementation TTLayoutSupportConstraint

+ (NSArray *)layoutSupportConstraintsWithView:(UIView *)view topLayoutGuide:(id<UILayoutSupport>)topLayoutGuide
{
    return @[
             [TTLayoutSupportConstraint constraintWithItem:topLayoutGuide
                                                 attribute:NSLayoutAttributeHeight
                                                 relatedBy:NSLayoutRelationEqual
                                                    toItem:nil
                                                 attribute:NSLayoutAttributeNotAnAttribute
                                                multiplier:1.0
                                                  constant:0.0],
             [TTLayoutSupportConstraint constraintWithItem:topLayoutGuide
                                                 attribute:NSLayoutAttributeTop
                                                 relatedBy:NSLayoutRelationEqual
                                                    toItem:view
                                                 attribute:NSLayoutAttributeTop
                                                multiplier:1.0
                                                  constant:0.0],
             ];
}

+ (NSArray *)layoutSupportConstraintsWithView:(UIView *)view bottomLayoutGuide:(id<UILayoutSupport>)bottomLayoutGuide
{
    return @[
             [TTLayoutSupportConstraint constraintWithItem:bottomLayoutGuide
                                                 attribute:NSLayoutAttributeHeight
                                                 relatedBy:NSLayoutRelationEqual
                                                    toItem:nil
                                                 attribute:NSLayoutAttributeNotAnAttribute
                                                multiplier:1.0
                                                  constant:0.0],
             [TTLayoutSupportConstraint constraintWithItem:bottomLayoutGuide
                                                 attribute:NSLayoutAttributeBottom
                                                 relatedBy:NSLayoutRelationEqual
                                                    toItem:view
                                                 attribute:NSLayoutAttributeBottom
                                                multiplier:1.0
                                                  constant:0.0],
             ];
}

@end
stefreak
  • 1,460
  • 12
  • 30
  • Kudos for your solution. It inspired me to do a little experiment. Turns out that a layout guide constraint can be easily identified: `constraint.firstItem == childControllerTopLayoutGuide && constraint.secondItem == nil` and changing its constant seems to do the job (updates original `id` object). Have you evaluated such approach? – Błażej Oct 21 '14 at 19:24
  • that's a pretty cool idea, should simplify that solution a lot. I will try that and maybe incorporate that into my answer. thanks :) – stefreak Oct 22 '14 at 09:13
  • is that actually use of private APIs or not? The _UILayoutSupportConstraint class is private, is it not forbidden to send messages to it? – stefreak Oct 22 '14 at 09:17
  • I think it's fine. It's actually less invasive than modifying subviews of system controls (and apps doing that are not being rejected). After all these are just constraints inside _our_ views. Then again, iOS9 might bring changes to how these layout guides are implemented and this will break :] – Błażej Oct 23 '14 at 10:24
  • @Błażej i actually adopted your solution now, that makes it much more simple. cool idea! – stefreak Feb 13 '15 at 00:15
  • A better approach would be setting the top and bottom constraints on the guides rather than their height. Setting the priority of those newly created top and bottom constraints to, let's say, @900 will allow the container controller to actually 'constrain' the guides by adding required constraints. For instance, a custom container controller might set the top of the child controller's top guide to the bottom of its navigation bar. It will also work well if the container controller decides to hide the bar. With current implementation it will have to update the length property. – Danchoys Mar 12 '15 at 11:29
  • Even better, to mimic the UINavigationController behavior, a custom container controller may set two constraints: contentController.topLayoutGuide.Top = self.navigationBar.Bottom (@999) and contentController.topLayoutGuide.Top <= self.topLayoutGuide.Top (@1000). This way the content won't go any further than the container controller's own top layout guide, which is usually 20pt off the top edge (but may be different if our controller is nested inside another container controller, i.e. navigation controller). – Danchoys Mar 12 '15 at 11:33
  • @Danchoys did you test that? does the layoutGuide's length property still work for non-autolayout code? – stefreak Mar 13 '15 at 15:28
  • I did test that and it worked, though to be honest I have completely forgotten about the non-autolayout side of things. – Danchoys Mar 13 '15 at 16:43
  • Interesting approach. Unfortunately, `UITableViewController` doesn't consider these constraints to manage `contentInset`. I thought this might be a solution to fix the bottom inset after adding banner-like views at the bottom but alas it doesn't help. – Ortwin Gentz May 21 '15 at 19:47
  • @Błażej are you sure that changing the `_UILayoutSupportConstraint` constant updates the `_UILayoutGuide` length or frame? In my tests this didn't work. In fact, updating the constant wasn't even persisted in the constraint. – Ortwin Gentz May 21 '15 at 19:52
  • @OrtwinGentz AFAIR I ended up observing (KVO) the layout guide and adjusting it value every time it changed to an undesired value. Hacky, but worked as expected :) – Błażej May 23 '15 at 20:59
  • @Danchoys Can you change layout guide value/position by constraining it? If so, it should work for Ortwin Gentz as well. That would be a perfect solution. I have to give it a try! Thanks for inspiration :) – Błażej May 23 '15 at 21:02
  • When I tried it some time ago I didn't get it working. But maybe it changed with some iOS version. in the meantime I'm improving and simplifying TTLayoutSupport greatly to support constraining the layout guides instead of just setting the length: https://github.com/stefreak/TTLayoutSupport/pull/6 – stefreak May 23 '15 at 22:51
  • 1
    This is no longer needed, the valid layout guide class to return from topLayoutGuide and bottomLayoutGuide has been figured out and works on iOS 9: http://stackoverflow.com/a/33215299/259521 – malhal Jan 11 '16 at 00:45
1

I think they mean you should constrain the layout guides using autolayout, i.e. an NSLayoutConstraint object, instead of manually setting the length property. The length property is made available for classes that choose not to use autolayout, but it seems with custom container view controllers you do not have this choice.

I assume the best practice is make the priority of the constraint in the container view controller that "sets" the value of the length property to UILayoutPriorityRequired.

I'm not sure what layout attribute you would bind, either NSLayoutAttributeHeight or NSLayoutAttributeBottom probably.

jamesmoschou
  • 1,173
  • 8
  • 15
  • This seems perfectly reasonable; unfortunately, it does not work, as the layout guide is already completely constrained. – Jesse Rusak Jul 21 '14 at 18:41
  • 2
    @JesseRusak how are you setting up your constraints so that this works on the iOS 8 beta? All my experiments produced warnings about ambiguous constraints. – Sven Jul 29 '14 at 19:14
  • 1
    @Sven I just created a single constraint which specified the NSLayoutAttributeBottom of child.topLayoutConstraint to something in my container view. I didn't do anything fancy; if it's not working, perhaps make a minimal example and post a question? (Please poke me if you do.) – Jesse Rusak Jul 29 '14 at 19:17
  • @JesseRusak I couldn't get this to work either. I assume if you add such a constraint you're actually constraining your "something in my container view" to the unchanged topLayoutGuide. Not the other way round. – Ortwin Gentz May 21 '15 at 20:22
  • @OrtwinGentz Constraints are always two-way, so I'm not sure what you mean. As I suggested earlier, if you have a case where this doesn't work, I'd encourage you to post a new question and link it here. – Jesse Rusak May 21 '15 at 20:37
  • @JesseRusak you suggested you can change the layoutGuides by adding custom constraints. That doesn't seem to be the case. I'm giving up on trying to change the layoutGuides. It's fighting against the frameworks, it seems. – Ortwin Gentz May 21 '15 at 21:07
0

In the parent view controller

- (void)viewDidLayoutSubviews {

    [super viewDidLayoutSubviews];

    for (UIViewController * childViewController in self.childViewControllers) {

        // Pass the layouts to the child
        if ([childViewController isKindOfClass:[MyCustomViewController class]]) {
            [(MyCustomViewController *)childViewController parentTopLayoutGuideLength:self.topLayoutGuide.length parentBottomLayoutGuideLength:self.bottomLayoutGuide.length];
        }

    }

}

and than pass the values to the children, you can have a custom class as in my example, a protocol, or you can maybe access the scroll view from the child's hierarchy

Peter Lapisu
  • 19,915
  • 16
  • 123
  • 179