Navigation controller top layout guide not honored with custom transition

19.6k views Asked by At

Short version:

I am having a problem with auto layout top layout guide when used in conjunction with custom transition and UINavigationController in iOS7. Specifically, the constraint between the top layout guide and the text view is not being honored. Has anyone encountered this issue?


Long version:

I have a scene which has unambiguously define constraints (i.e. top, bottom, left and right) that renders a view like so:

right

But when I use this with a custom transition on the navigation controller, the top constraint to the top layout guide seems off and it renders is as follows, as if the top layout guide was at the top of the screen, rather than at the bottom of the navigation controller:

wrong

It would appear that the "top layout guide" with the navigation controller is getting confused when employing the custom transition. The rest of the constraints are being applied correctly. And if I rotate the device and rotate it again, everything is suddenly rendered correctly, so it does not appear to be not a matter that the constraints are not defined properly. Likewise, when I turn off my custom transition, the views render correctly.

Having said that, _autolayoutTrace is reporting that the UILayoutGuide objects suffer from AMBIGUOUS LAYOUT, when I run:

(lldb) po [[UIWindow keyWindow] _autolayoutTrace]

But those layout guides are always reported as ambiguous whenever I look at them even though I've ensured that there are no missing constraints (I've done the customary selecting of view controller and choosing "Add missing constraints for view controller" or selecting all of the controls and doing the same for them).

In terms of how precisely I'm doing the transition, I've specified an object that conforms to UIViewControllerAnimatedTransitioning in the animationControllerForOperation method:

- (id<UIViewControllerAnimatedTransitioning>)navigationController:(UINavigationController *)navigationController
                                  animationControllerForOperation:(UINavigationControllerOperation)operation
                                               fromViewController:(UIViewController*)fromVC
                                                 toViewController:(UIViewController*)toVC
{
    if (operation == UINavigationControllerOperationPush)
        return [[PushAnimator alloc] init];

    return nil;
}

And

@implementation PushAnimator

- (NSTimeInterval)transitionDuration:(id <UIViewControllerContextTransitioning>)transitionContext
{
    return 0.5;
}

- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext
{
    UIViewController* toViewController   = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
    UIViewController* fromViewController = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];

    [[transitionContext containerView] addSubview:toViewController.view];
    CGFloat width = fromViewController.view.frame.size.width;

    toViewController.view.transform = CGAffineTransformMakeTranslation(width, 0);

    [UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{
        fromViewController.view.transform = CGAffineTransformMakeTranslation(-width / 2.0, 0);
        toViewController.view.transform = CGAffineTransformIdentity;
    } completion:^(BOOL finished) {
        fromViewController.view.transform = CGAffineTransformIdentity;
        [transitionContext completeTransition:![transitionContext transitionWasCancelled]];
    }];
}

@end

I've also done a rendition of the above, setting the frame of the view rather than the transform, with the same result.

I've also tried manually make sure that the constraints are re-applied by calling layoutIfNeeded. I've also tried setNeedsUpdateConstraints, setNeedsLayout, etc.

Bottom line, has anyone successfully married custom transition of navigation controller with constraints that use top layout guide?

12

There are 12 answers

4
Alex Pretzlav On BEST ANSWER

I solved this by fixing the height constraint of the topLayoutGuide. Adjusting edgesForExtendedLayout wasn't an option for me, as I needed the destination view to underlap the navigation bar, but also to be able to layout subviews using topLayoutGuide.

Directly inspecting the constraints in play shows that iOS adds a height constraint to the topLayoutGuide with value equal to the height of the navigation bar of the navigation controller. Except, in iOS 7, using a custom animation transition leaves the constraint with a height of 0. They fixed this in iOS 8.

This is the solution I came up with to correct the constraint (it's in Swift but the equivalent should work in Obj-C). I've tested that it works on iOS 7 and 8.

func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
    let fromView = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)!.view
    let destinationVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey)!
    destinationVC.view.frame = transitionContext.finalFrameForViewController(destinationVC)
    let container = transitionContext.containerView()
    container.addSubview(destinationVC.view)

    // Custom transitions break topLayoutGuide in iOS 7, fix its constraint
    if let navController = destinationVC.navigationController {
        for constraint in destinationVC.view.constraints() as [NSLayoutConstraint] {
            if constraint.firstItem === destinationVC.topLayoutGuide
                && constraint.firstAttribute == .Height
                && constraint.secondItem == nil
                && constraint.constant == 0 {
                constraint.constant = navController.navigationBar.frame.height
            }
        }
    }

    // Perform your transition animation here ...
}
2
Jasper Kuperus On

I struggled with the exact same problem. Putting this in the viewDidLoad of my toViewController really helped me out:

self.edgesForExtendedLayout = UIRectEdgeNone;

This did not solve all my issues and I'm still looking for a better approach, but this certainly made it a bit easier.

0
vivek bhoraniya On

try :

self.edgesforextendedlayout=UIRectEdgeNone

Or just set navigationbar opaque and set background image or backgroundcolor to navigationbar

0
Yusuf terzi On

i found way. First uncheck "Extend Edges" property of controller. after that navigation bar getting dark color. Add a view to controller and set top and bottom LayoutConstraint -100. Then make view's clipsubview property no (for navigaionbar transculent effect). My english bad sory for that. :)

0
stringCode On

I had the same problem, ended up implementing my own topLayout guide view and making constraints to it rather then to topLayoutGuide. Not ideal. Only posting it here in case someone is stuck and looking for quick hacky solution http://www.github.com/stringcode86/SCTopLayoutGuide

0
hpique On

As @Rob mentioned, topLayoutGuide is not reliable when using custom transitions in UINavigationController. I worked around this by using my own layout guide. You can see the code in action in this demo project. Highlights:

A category for custom layout guides:

@implementation UIViewController (hp_layoutGuideFix)

- (BOOL)hp_usesTopLayoutGuideInConstraints
{
    return NO;
}

- (id<UILayoutSupport>)hp_topLayoutGuide
{
    id<UILayoutSupport> object = objc_getAssociatedObject(self, @selector(hp_topLayoutGuide));
    return object ? : self.topLayoutGuide;
}

- (void)setHp_topLayoutGuide:(id<UILayoutSupport>)hp_topLayoutGuide
{
    HPLayoutSupport *object = objc_getAssociatedObject(self, @selector(hp_topLayoutGuide));
    if (object != nil && self.hp_usesTopLayoutGuideInConstraints)
    {
        [object removeFromSuperview];
    }
    HPLayoutSupport *layoutGuide = [[HPLayoutSupport alloc] initWithLength:hp_topLayoutGuide.length];
    if (self.hp_usesTopLayoutGuideInConstraints)
    {
        [self.view addSubview:layoutGuide];
    }
    objc_setAssociatedObject(self, @selector(hp_topLayoutGuide), layoutGuide, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

@end

HPLayoutSupport is the class that will act as a layout guide. It has to be a UIView subclass to avoid crashes (I wonder why this isn't part of the UILayoutSupport interface).

@implementation HPLayoutSupport {
    CGFloat _length;
}

- (id)initWithLength:(CGFloat)length
{
    self = [super init];
    if (self)
    {
        self.translatesAutoresizingMaskIntoConstraints = NO;
        self.userInteractionEnabled = NO;
        _length = length;
    }
    return self;
}

- (CGSize)intrinsicContentSize
{
    return CGSizeMake(1, _length);
}

- (CGFloat)length
{
    return _length;
}

@end

The UINavigationControllerDelegate is the one responsible for "fixing" the layout guide before the transition:

- (id <UIViewControllerAnimatedTransitioning>)navigationController:(UINavigationController *)navigationController
                                   animationControllerForOperation:(UINavigationControllerOperation)operation
                                                fromViewController:(UIViewController *)fromVC
                                                  toViewController:(UIViewController *)toVC
{
    toVC.hp_topLayoutGuide = fromVC.hp_topLayoutGuide;
    id <UIViewControllerAnimatedTransitioning> animator;
    // Initialise animator
    return animator;
}

Finally, the UIViewController uses hp_topLayoutGuide instead of topLayoutGuide in the constraints, and indicates this by overriding hp_usesTopLayoutGuideInConstraints:

- (void)updateViewConstraints
{
    [super updateViewConstraints];
    id<UILayoutSupport> topLayoutGuide = self.hp_topLayoutGuide;
    // Example constraint
    NSDictionary *views = NSDictionaryOfVariableBindings(_imageView, _dateLabel, topLayoutGuide);
    NSArray *constraints = [NSLayoutConstraint constraintsWithVisualFormat:@"V:|[topLayoutGuide][_imageView(240)]-8-[_dateLabel]" options:NSLayoutFormatAlignAllCenterX metrics:nil views:views];
    [self.view addConstraints:constraints];
}

- (BOOL)hp_usesTopLayoutGuideInConstraints
{
    return YES;
}

Hope it helps.

1
John Jacecko On

Here's the simple solution I'm using that's working great for me: during the setup phase of - (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext, manually set your "from" and "to" viewController.view.frame.origin.y = navigationController.navigationBar.frame.size.height. It'll make your auto layout views position themselves vertically as you expect.

Minus the pseudo-code (e.g. you probably have your own way of determining if a device is running iOS7), this is what my method looks like:

- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext
{
    UIViewController *fromViewController = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
    UIViewController *toViewController = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
    UIView *container = [transitionContext containerView];

    CGAffineTransform destinationTransform;
    UIViewController *targetVC;
    CGFloat adjustmentForIOS7AutoLayoutBug = 0.0f;

    // We're doing a view controller POP
    if(self.isViewControllerPop)
    {
        targetVC = fromViewController;

        [container insertSubview:toViewController.view belowSubview:fromViewController.view];

        // Only need this auto layout hack in iOS7; it's fixed in iOS8
        if(_device_is_running_iOS7_)
        {
            adjustmentForIOS7AutoLayoutBug = toViewController.navigationController.navigationBar.frame.size.height;
            [toViewController.view setFrameOriginY:adjustmentForIOS7AutoLayoutBug];
        }

        destinationTransform = CGAffineTransformMakeTranslation(fromViewController.view.bounds.size.width,adjustmentForIOS7AutoLayoutBug);
    }
    // We're doing a view controller PUSH
    else
    {
        targetVC = toViewController;

        [container addSubview:toViewController.view];

        // Only need this auto layout hack in iOS7; it's fixed in iOS8
        if(_device_is_running_iOS7_)
        {
            adjustmentForIOS7AutoLayoutBug = toViewController.navigationController.navigationBar.frame.size.height;
        }

        toViewController.view.transform = CGAffineTransformMakeTranslation(toViewController.view.bounds.size.width,adjustmentForIOS7AutoLayoutBug);
        destinationTransform = CGAffineTransformMakeTranslation(0.0f,adjustmentForIOS7AutoLayoutBug);
    }

    [UIView animateWithDuration:_animation_duration_
                          delay:_animation_delay_if_you_need_one_
                        options:([transitionContext isInteractive] ? UIViewAnimationOptionCurveLinear : UIViewAnimationOptionCurveEaseOut)
                     animations:^(void)
     {
         targetVC.view.transform = destinationTransform;
     }
                     completion:^(BOOL finished)
     {
         [transitionContext completeTransition:([transitionContext transitionWasCancelled] ? NO : YES)];
     }];
}

A couple of bonus things about this example:

  • For view controller pushes, this custom transition slides the pushed toViewController.view on top of the unmoving fromViewController.view. For pops, fromViewController.view slides off to the right and reveals an unmoving toViewController.view under it. All in all, it's just a subtle twist on the stock iOS7+ view controller transition.
  • The [UIView animateWithDuration:...] completion block shows the correct way to handle completed & cancelled custom transitions. This tiny tidbit was a classic head-slap moment; hope it helps somebody else out there.

Lastly, I'd like to point out that as far as I can tell, this is an iOS7-only issue that has been fixed in iOS8: my custom view controller transition that is broken in iOS7 works just fine in iOS8 without modification. That being said, you should verify that this is what you're seeing too, and if so, only run the fix on devices running iOS7.x. As you can see in the code example above, the y-adjustment value is 0.0f unless the device is running iOS7.x.

0
Rob On

FYI, I ended up employing a variation of Alex's answer, programmatically changing the top layout guide's height constraint constant in the animateTransition method. I'm only posting this to share the Objective-C rendition (and eliminate the constant == 0 test).

CGFloat navigationBarHeight = toViewController.navigationController.navigationBar.frame.size.height;

for (NSLayoutConstraint *constraint in toViewController.view.constraints) {
    if (constraint.firstItem == toViewController.topLayoutGuide
        && constraint.firstAttribute == NSLayoutAttributeHeight
        && constraint.secondItem == nil
        && constraint.constant < navigationBarHeight) {
        constraint.constant += navigationBarHeight;
    }
}

Thanks, Alex.

0
skabob11 On

I ran into this same issue but without using a UINavigationController and just positioning a view off of the topLayoutGuide. The layout would be correct when first displayed, a transition would take place to another view, and then upon exiting and returning to the first view, the layout would be broken as that topLayoutGuide would no longer be there.

I solved this problem by capturing the safe area insets prior to the transition and then reimplementing them, not by adjusting my constraints, but by setting them on the viewController's additionalSafeAreaInsets.

I found this solution to work well as I don't have to adjust any of my layout code and search through constraints and I can just reimplementing the space that was there previously. This could be more difficult if you are actually using the additionalSafeAreaInsets property.

Example

I added a variable to my transitionManager to capture the safe insets that exist when the transitionManager is created.

class MyTransitionManager: NSObject, UIViewControllerAnimatedTransitioning, UIViewControllerTransitioningDelegate {

    private var presenting = true
    private var container:UIView?
    private var safeInsets:UIEdgeInsets?

    ...

Then during the entering transition I save those insets.

    let toView = viewControllers.to.view
    let fromView = viewControllers.from.view

    if #available(iOS 11.0, *) {
        safeInsets = toView.safeAreaInsets
    }

In the case of the iPhone X this looks something like UIEdgeInsets(top: 44.0, left: 0.0, bottom: 34.0, right: 0.0)

Now when exiting, the insets on that same view we transitioned from in the entrance will be .zero so we add our captured insets to the additionalSafeAreaInsets on the viewController, which will set them on our view for us as well as update the layout. Once our animation is done, we reset the additionalSafeAreaInsets back to .zero.

    if #available(iOS 11.0, *) {
        if safeInsets != nil {
            viewControllers.to.additionalSafeAreaInsets = safeInsets!
        }
    }

    ...then in the animation completion block

    if #available(iOS 11.0, *) {
        if self.safeInsets != nil {
            viewControllers.to.additionalSafeAreaInsets = .zero
        }
    }

    transitionContext.completeTransition(true)
0
Aнгел On

In storyboard add another vertical constraint to main view's top. I have the same problem too but adding that constraint help me to avoid manual constraints. See screenshot here link

Other solution is to calculate toVC frame... something like this:

float y = toVC.navigationController.navigationBar.frame.origin.y + toVC.navigationController.navigationBar.frame.size.height;
toVC.view.frame = CGRectMake(0, y, toVC.view.frame.size.width, toVC.view.frame.size.height - y);

Let me know if you have found a better solution. I have been struggling with this issue as well and I came up with previous ideas.

0
Cocody On

Just put the following code toviewDidLoad

self.extendedLayoutIncludesOpaqueBars = YES;
5
Hamdan Javed On

Managed to fix my issue by adding this line:

toViewController.view.frame = [transitionContext finalFrameForViewController:toViewController];

To:

- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext fromVC:(UIViewController *)fromVC toVC:(UIViewController *)toVC fromView:(UIView *)fromView toView:(UIView *)toView {

    // Add the toView to the container
    UIView* containerView = [transitionContext containerView];
    [containerView addSubview:toView];
    [containerView sendSubviewToBack:toView];


    // animate
    toVC.view.frame = [transitionContext finalFrameForViewController:toVC];
    NSTimeInterval duration = [self transitionDuration:transitionContext];
    [UIView animateWithDuration:duration animations:^{
        fromView.alpha = 0.0;
    } completion:^(BOOL finished) {
        if ([transitionContext transitionWasCancelled]) {
            fromView.alpha = 1.0;
        } else {
            // reset from- view to its original state
            [fromView removeFromSuperview];
            fromView.alpha = 1.0;
        }
        [transitionContext completeTransition:![transitionContext transitionWasCancelled]];
    }];

}

From Apple's Documentation for [finalFrameForViewController] : https://developer.apple.com/library/ios/documentation/UIKit/Reference/UIViewControllerContextTransitioning_protocol/#//apple_ref/occ/intfm/UIViewControllerContextTransitioning/finalFrameForViewController: